You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
// 设计取舍:
|
||||
// - Version 在 (Requirement, Phase) 内自增(max+1),由 service 计算后落库;
|
||||
// UNIQUE(requirement_id, phase, version) 兜底竞争,Create 捕获 unique_violation 重试 1 次。
|
||||
// - Phase 仅允许 ArtifactPhases(planning/prototyping/auditing),与 DB CHECK 一致。
|
||||
// - Phase 仅允许 ArtifactPhases(planning/prototyping/auditing/implementing/reviewing),与 DB CHECK 一致。
|
||||
// - Approve 给某版本打 verdict(pass/fail);verdict=pass 时把阶段指针指向该产物,
|
||||
// 供 RequirementService 的阶段网关放行后续 phase。
|
||||
// - 已 closed requirement:禁止新建产物版本,返回 CodeRequirementClosed。
|
||||
// - 已归档 project:所有写操作经 loadProjectForWrite 拒绝。
|
||||
// - 读操作经 loadProjectForRead 校验可见性。
|
||||
@@ -30,7 +32,7 @@ func NewArtifactService(repo Repository, rec audit.Recorder) ArtifactService {
|
||||
|
||||
func (s *artifactService) Create(ctx context.Context, c Caller, slug string, reqNumber int, in CreateArtifactInput) (*Artifact, error) {
|
||||
if !in.Phase.IsArtifactPhase() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一")
|
||||
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing/implementing/reviewing 之一")
|
||||
}
|
||||
if in.Content == "" {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "content 必填")
|
||||
@@ -82,7 +84,7 @@ func (s *artifactService) createWithRetry(ctx context.Context, reqID, createdBy
|
||||
|
||||
func (s *artifactService) List(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase) ([]*Artifact, error) {
|
||||
if phase != "" && !phase.IsArtifactPhase() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一")
|
||||
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing/implementing/reviewing 之一")
|
||||
}
|
||||
p, err := loadProjectForRead(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
@@ -97,7 +99,7 @@ func (s *artifactService) List(ctx context.Context, c Caller, slug string, reqNu
|
||||
|
||||
func (s *artifactService) GetByVersion(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase, version int) (*Artifact, error) {
|
||||
if !phase.IsArtifactPhase() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一")
|
||||
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing/implementing/reviewing 之一")
|
||||
}
|
||||
p, err := loadProjectForRead(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
@@ -110,6 +112,44 @@ func (s *artifactService) GetByVersion(ctx context.Context, c Caller, slug strin
|
||||
return s.repo.GetArtifactByVersion(ctx, r.ID, phase, version)
|
||||
}
|
||||
|
||||
func (s *artifactService) Approve(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase, version int, verdict Verdict) (*Artifact, error) {
|
||||
if !phase.IsArtifactPhase() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing/implementing/reviewing 之一")
|
||||
}
|
||||
if verdict != VerdictPass && verdict != VerdictFail {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "verdict 必须是 pass 或 fail")
|
||||
}
|
||||
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Status == StatusClosed {
|
||||
return nil, errs.New(errs.CodeRequirementClosed, "已关闭需求禁止批准产物")
|
||||
}
|
||||
art, err := s.repo.GetArtifactByVersion(ctx, r.ID, phase, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := s.repo.ApproveArtifact(ctx, art.ID, verdict)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// verdict=pass 才把阶段指针指向此产物(网关据此放行);fail 仅落 verdict、不动指针。
|
||||
if verdict == VerdictPass {
|
||||
if _, err := s.repo.UpsertPhasePointer(ctx, r.ID, phase, out.ID, c.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.recordAudit(ctx, c, "requirement_artifact.approve", out.ID.String(), map[string]any{
|
||||
"requirement_id": r.ID.String(), "phase": string(phase), "version": version, "verdict": string(verdict),
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *artifactService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func newArtifactSvc(repo *fakeRepo) (ArtifactService, RequirementService, ProjectService, *spyAudit) {
|
||||
spy := &spyAudit{}
|
||||
return NewArtifactService(repo, spy), NewRequirementService(repo, spy), NewProjectService(repo, spy), spy
|
||||
return NewArtifactService(repo, spy), NewRequirementService(repo, spy, nil, nil), NewProjectService(repo, spy), spy
|
||||
}
|
||||
|
||||
func TestArtifactService_Create_VersionAutoIncrement(t *testing.T) {
|
||||
@@ -68,7 +68,7 @@ func TestArtifactService_Create_RejectsNonArtifactPhase(t *testing.T) {
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, phase := range []Phase{PhaseImplementing, PhaseReviewing, PhaseDone, Phase("bogus"), Phase("")} {
|
||||
for _, phase := range []Phase{PhaseDone, Phase("bogus"), Phase("")} {
|
||||
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: phase, Content: "x"})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok, "phase=%s", phase)
|
||||
@@ -247,3 +247,124 @@ func TestArtifactService_List_ForbiddenForOutsider(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
}
|
||||
|
||||
func TestArtifactService_Create_AcceptsImplementingReviewing(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 扩展后的 ArtifactPhases 接受 implementing/reviewing。
|
||||
require.True(t, PhaseImplementing.IsArtifactPhase())
|
||||
require.True(t, PhaseReviewing.IsArtifactPhase())
|
||||
for _, phase := range []Phase{PhaseImplementing, PhaseReviewing} {
|
||||
a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: phase, Content: "x"})
|
||||
require.NoError(t, err, "phase=%s", phase)
|
||||
require.Equal(t, VerdictNone, a.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactService_Approve_SetsVerdictAndPointer(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, _, _, spy := newArtifactSvc(repo)
|
||||
r := seedRequirement(t, repo, owner, nil)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
_, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"})
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, VerdictPass, out.Verdict)
|
||||
|
||||
ptr, err := repo.GetPhasePointer(context.Background(), r.ID, PhaseAuditing)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ptr.ApprovedArtifactID)
|
||||
require.Equal(t, out.ID, *ptr.ApprovedArtifactID)
|
||||
require.Contains(t, spy.actions(), "requirement_artifact.approve")
|
||||
}
|
||||
|
||||
func TestArtifactService_Approve_FailDoesNotSetPointer(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, _, _, _ := newArtifactSvc(repo)
|
||||
r := seedRequirement(t, repo, owner, nil)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
_, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"})
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictFail)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, VerdictFail, out.Verdict)
|
||||
|
||||
_, err = repo.GetPhasePointer(context.Background(), r.ID, PhaseAuditing)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
}
|
||||
|
||||
func TestArtifactService_Approve_MovesPointerToNewerVersion(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, _, _, _ := newArtifactSvc(repo)
|
||||
r := seedRequirement(t, repo, owner, nil)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
_, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "v1"})
|
||||
require.NoError(t, err)
|
||||
v2, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "v2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass)
|
||||
require.NoError(t, err)
|
||||
_, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 2, VerdictPass)
|
||||
require.NoError(t, err)
|
||||
|
||||
ptr, err := repo.GetPhasePointer(context.Background(), r.ID, PhaseAuditing)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, v2.ID, *ptr.ApprovedArtifactID)
|
||||
}
|
||||
|
||||
func TestArtifactService_Approve_BlockedWhenClosed(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, reqSvc, _, _ := newArtifactSvc(repo)
|
||||
r := seedRequirement(t, repo, owner, nil)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
_, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number))
|
||||
|
||||
_, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeRequirementClosed, ae.Code)
|
||||
}
|
||||
|
||||
func TestArtifactService_Approve_RejectsInvalidVerdict(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, _, _, _ := newArtifactSvc(repo)
|
||||
r := seedRequirement(t, repo, owner, nil)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
_, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictNone)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// mkIssue 在 demo 项目创建一个游离 issue,返回其编号。
|
||||
func mkIssue(t *testing.T, svc IssueService, caller Caller, title string) int {
|
||||
t.Helper()
|
||||
i, err := svc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: title})
|
||||
require.NoError(t, err)
|
||||
return i.Number
|
||||
}
|
||||
|
||||
func TestCreateSubtask_SetsParentAndPriority(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
parentNum := mkIssue(t, svc, caller, "Parent")
|
||||
prio := 2
|
||||
sub, err := svc.CreateSubtask(context.Background(), caller, "demo", parentNum,
|
||||
CreateIssueInput{Title: "Child", Priority: &prio})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sub.ParentID)
|
||||
require.Equal(t, 2, sub.Priority)
|
||||
|
||||
// 父不存在 → NotFound
|
||||
_, err = svc.CreateSubtask(context.Background(), caller, "demo", 9999, CreateIssueInput{Title: "X"})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
}
|
||||
|
||||
func TestCreateIssue_RejectsBadPriority(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
bad := 5
|
||||
_, err := svc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "X", Priority: &bad})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestAddDependency_RejectsSelf(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
a := mkIssue(t, svc, caller, "A")
|
||||
_, err := svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: a, BlockerNumber: a})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestAddDependency_RejectsDuplicate(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
a := mkIssue(t, svc, caller, "A")
|
||||
b := mkIssue(t, svc, caller, "B")
|
||||
_, err := svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: a, BlockerNumber: b})
|
||||
require.NoError(t, err)
|
||||
_, err = svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: a, BlockerNumber: b})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestAddDependency_RejectsDirectCycle(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
a := mkIssue(t, svc, caller, "A")
|
||||
b := mkIssue(t, svc, caller, "B")
|
||||
// A blocked-by B
|
||||
_, err := svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: a, BlockerNumber: b})
|
||||
require.NoError(t, err)
|
||||
// B blocked-by A → 直接环,应拒绝
|
||||
_, err = svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: b, BlockerNumber: a})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestAddDependency_RejectsTransitiveCycle(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
a := mkIssue(t, svc, caller, "A")
|
||||
b := mkIssue(t, svc, caller, "B")
|
||||
c := mkIssue(t, svc, caller, "C")
|
||||
// A blocked-by B, B blocked-by C
|
||||
_, err := svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: a, BlockerNumber: b})
|
||||
require.NoError(t, err)
|
||||
_, err = svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: b, BlockerNumber: c})
|
||||
require.NoError(t, err)
|
||||
// C blocked-by A → A->B->C->A 传递环,应拒绝
|
||||
_, err = svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: c, BlockerNumber: a})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestAddDependency_AllowsValidDAG(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
a := mkIssue(t, svc, caller, "A")
|
||||
b := mkIssue(t, svc, caller, "B")
|
||||
c := mkIssue(t, svc, caller, "C")
|
||||
// A blocked-by B, A blocked-by C —— 合法 DAG(无环)
|
||||
_, err := svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: a, BlockerNumber: b})
|
||||
require.NoError(t, err)
|
||||
_, err = svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: a, BlockerNumber: c})
|
||||
require.NoError(t, err)
|
||||
|
||||
blockedBy, blocks, err := svc.ListDependencies(context.Background(), caller, "demo", a)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, blockedBy, 2)
|
||||
require.Len(t, blocks, 0)
|
||||
}
|
||||
|
||||
func TestRemoveDependency(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
a := mkIssue(t, svc, caller, "A")
|
||||
b := mkIssue(t, svc, caller, "B")
|
||||
_, err := svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: a, BlockerNumber: b})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, svc.RemoveDependency(context.Background(), caller, "demo", a, b))
|
||||
// 再删 → NotFound
|
||||
err = svc.RemoveDependency(context.Background(), caller, "demo", a, b)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
|
||||
blockedBy, _, err := svc.ListDependencies(context.Background(), caller, "demo", a)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, blockedBy, 0)
|
||||
}
|
||||
|
||||
func TestListReadyLeafSubtasks_OnlyUnblockedOpen(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newIssueSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
parent := mkIssue(t, svc, caller, "Parent")
|
||||
prio1 := 1
|
||||
prio3 := 3
|
||||
s1, err := svc.CreateSubtask(context.Background(), caller, "demo", parent, CreateIssueInput{Title: "S1", Priority: &prio1})
|
||||
require.NoError(t, err)
|
||||
s2, err := svc.CreateSubtask(context.Background(), caller, "demo", parent, CreateIssueInput{Title: "S2", Priority: &prio3})
|
||||
require.NoError(t, err)
|
||||
blocker := mkIssue(t, svc, caller, "Blocker")
|
||||
s3, err := svc.CreateSubtask(context.Background(), caller, "demo", parent, CreateIssueInput{Title: "S3"})
|
||||
require.NoError(t, err)
|
||||
// S3 blocked-by Blocker(未关闭)→ 不就绪
|
||||
_, err = svc.AddDependency(context.Background(), caller, "demo",
|
||||
AddDependencyInput{BlockedNumber: s3.Number, BlockerNumber: blocker})
|
||||
require.NoError(t, err)
|
||||
|
||||
proj, err := repo.GetProjectBySlug(context.Background(), "demo")
|
||||
require.NoError(t, err)
|
||||
ready, err := repo.ListReadyLeafSubtasks(context.Background(), proj.ID, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := map[uuid.UUID]bool{}
|
||||
for _, i := range ready {
|
||||
got[i.ID] = true
|
||||
}
|
||||
require.True(t, got[s1.ID])
|
||||
require.True(t, got[s2.ID])
|
||||
require.False(t, got[s3.ID], "S3 被未关闭 blocker 阻塞,不应就绪")
|
||||
}
|
||||
+176
-3
@@ -40,9 +40,10 @@ func (p Phase) IsValid() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ArtifactPhases 是允许产出文档型产物的三个阶段(与 requirement_artifacts.phase
|
||||
// CHECK 约束一致):实施/验收阶段的产出是代码(走 ACP),不在此列。
|
||||
var ArtifactPhases = []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing}
|
||||
// ArtifactPhases 是允许产出文档型产物的五个阶段(与 requirement_artifacts.phase
|
||||
// CHECK 约束一致):implementing/reviewing 的产出是审计报告 / 评审记录(也是文档型
|
||||
// 产物),done 阶段是汇总视图、不单独建产物,故只留前五阶段。
|
||||
var ArtifactPhases = []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing, PhaseImplementing, PhaseReviewing}
|
||||
|
||||
// IsArtifactPhase 报告 p 是否允许创建产物版本。
|
||||
func (p Phase) IsArtifactPhase() bool {
|
||||
@@ -54,6 +55,23 @@ func (p Phase) IsArtifactPhase() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verdict 是单个产物版本的评审结论,与 requirement_artifacts.verdict CHECK 一致。
|
||||
// 'none' 为默认(未评审);'pass' 表示通过、可作为阶段网关的"已批准产物";'fail'
|
||||
// 表示驳回。仅 'pass' 会被 ArtifactService.Approve 写入阶段指针。
|
||||
type Verdict string
|
||||
|
||||
// 评审结论枚举值。
|
||||
const (
|
||||
VerdictNone Verdict = "none"
|
||||
VerdictPass Verdict = "pass"
|
||||
VerdictFail Verdict = "fail"
|
||||
)
|
||||
|
||||
// IsValid 报告 v 是否在枚举集合内。
|
||||
func (v Verdict) IsValid() bool {
|
||||
return v == VerdictNone || v == VerdictPass || v == VerdictFail
|
||||
}
|
||||
|
||||
// Status 是 Requirement / Issue 通用的开/关状态。
|
||||
type Status string
|
||||
|
||||
@@ -127,6 +145,22 @@ type Issue struct {
|
||||
ClosedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// ParentID 非 nil 表示本 issue 是某 issue 的子任务(任务分解)。父删除时
|
||||
// ON DELETE SET NULL 置空(orphan,不级联删子)。
|
||||
ParentID *uuid.UUID
|
||||
// Priority 0..3,越大越早被调度器选取。默认 0(normal)。
|
||||
Priority int
|
||||
}
|
||||
|
||||
// Dependency 表示一条 issue 依赖边:BlockedID 被 BlockerID 阻塞(blocked-by)。
|
||||
// BlockedID 仅在 BlockerID 关闭后才"就绪"。ProjectID 冗余存储用于作用域校验。
|
||||
type Dependency struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
BlockedID uuid.UUID
|
||||
BlockerID uuid.UUID
|
||||
CreatedBy uuid.UUID
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Artifact 是某条 Requirement 在某阶段的一个产物文档版本快照。Version 在
|
||||
@@ -143,6 +177,19 @@ type Artifact struct {
|
||||
SourceMessageID *int64
|
||||
CreatedBy uuid.UUID
|
||||
CreatedAt time.Time
|
||||
// Verdict 是该版本的评审结论(none/pass/fail)。新建产物默认 none。
|
||||
Verdict Verdict
|
||||
}
|
||||
|
||||
// PhasePointer 是某条 Requirement 在某阶段的"已批准/当前"产物指针。仅在首次
|
||||
// Approve(verdict=pass) 后才存在一行;ApprovedArtifactID 在被指向的产物删除时
|
||||
// 由 DB(ON DELETE SET NULL)置空。Done 等阶段网关据此判定前置产物是否就绪。
|
||||
type PhasePointer struct {
|
||||
RequirementID uuid.UUID
|
||||
Phase Phase
|
||||
ApprovedArtifactID *uuid.UUID
|
||||
ApprovedBy *uuid.UUID
|
||||
ApprovedAt *time.Time
|
||||
}
|
||||
|
||||
// Caller 表示发起请求的用户上下文。Service 层据此判定鉴权。
|
||||
@@ -151,6 +198,48 @@ type Caller struct {
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// Role 是 project_members.role 的角色枚举(与 0026 迁移的 CHECK 约束一致)。
|
||||
// 语义(在 owner+global-admin+internal-visibility 既有规则之上"叠加"授权,
|
||||
// 只增不减):
|
||||
//
|
||||
// admin 读 + 写 + 成员管理(owner 经迁移回填为本角色)
|
||||
// member 读 + 写(需求/issue 等)
|
||||
// viewer 只读
|
||||
//
|
||||
// RoleNone 是非成员的零值,仅在代码内部表示"无成员关系",不写入 DB。
|
||||
type Role string
|
||||
|
||||
// 角色枚举值。注意 DB CHECK 仅允许 viewer/member/admin;RoleNone 不入库。
|
||||
const (
|
||||
RoleNone Role = ""
|
||||
RoleViewer Role = "viewer"
|
||||
RoleMember Role = "member"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
// IsValid 报告 r 是否为可写入 DB 的合法角色(不含 RoleNone)。
|
||||
func (r Role) IsValid() bool {
|
||||
return r == RoleViewer || r == RoleMember || r == RoleAdmin
|
||||
}
|
||||
|
||||
// CanWrite 报告该角色是否授予写权限(member/admin)。
|
||||
func (r Role) CanWrite() bool { return r == RoleMember || r == RoleAdmin }
|
||||
|
||||
// CanRead 报告该角色是否授予读权限(任何已建立的成员关系都可读)。
|
||||
func (r Role) CanRead() bool { return r == RoleViewer || r == RoleMember || r == RoleAdmin }
|
||||
|
||||
// CanManageMembers 报告该角色是否可管理项目成员(仅 admin)。
|
||||
func (r Role) CanManageMembers() bool { return r == RoleAdmin }
|
||||
|
||||
// Member 是一条 project_members 关系:某用户在某 project 的角色。
|
||||
type Member struct {
|
||||
ProjectID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
Role Role
|
||||
AddedBy *uuid.UUID
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ProjectService 暴露 Project 聚合根的应用服务方法。包内有三个并列服务
|
||||
// (Project/Requirement/Issue),这里保留聚合名前缀以避免歧义。
|
||||
//
|
||||
@@ -163,6 +252,18 @@ type ProjectService interface {
|
||||
Update(ctx context.Context, c Caller, slug string, in UpdateProjectInput) (*Project, error)
|
||||
Archive(ctx context.Context, c Caller, slug string) error
|
||||
Unarchive(ctx context.Context, c Caller, slug string) error
|
||||
|
||||
// ===== 成员/角色 ACL(autonomy roadmap §11)=====
|
||||
|
||||
// ListMembers 返回 slug 项目的全部成员。要求 caller 对项目可读。
|
||||
ListMembers(ctx context.Context, c Caller, slug string) ([]*Member, error)
|
||||
// AddMember 新增或更新一名成员的角色。仅 project-admin 或 global-admin 可调用。
|
||||
AddMember(ctx context.Context, c Caller, slug string, in AddMemberInput) (*Member, error)
|
||||
// RemoveMember 移除一名成员。仅 project-admin 或 global-admin 可调用。
|
||||
RemoveMember(ctx context.Context, c Caller, slug string, userID uuid.UUID) error
|
||||
// MemberRole 返回 userID 在 projectID 的成员角色(非成员返回 RoleNone)。
|
||||
// 供 app 层 projectACL 桥接成员感知鉴权;不做可见性校验(调用方已校验)。
|
||||
MemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error)
|
||||
}
|
||||
|
||||
// RequirementService 暴露 Requirement 聚合根的应用服务方法。
|
||||
@@ -185,6 +286,19 @@ type IssueService interface {
|
||||
Assign(ctx context.Context, c Caller, projectSlug string, number int, assignee *uuid.UUID) (*Issue, error)
|
||||
Close(ctx context.Context, c Caller, projectSlug string, number int) error
|
||||
Reopen(ctx context.Context, c Caller, projectSlug string, number int) error
|
||||
|
||||
// ===== 任务分解 / 依赖图(autonomy roadmap §10)=====
|
||||
|
||||
// CreateSubtask 在 parentNumber 指向的 issue 下创建一个子任务(设置 parent_id +
|
||||
// priority),继承 project。父 issue 须与目标 project 同属。
|
||||
CreateSubtask(ctx context.Context, c Caller, projectSlug string, parentNumber int, in CreateIssueInput) (*Issue, error)
|
||||
// AddDependency 新增一条依赖边:blocked 被 blocker 阻塞。拒绝自依赖、跨 project、
|
||||
// 重复、以及会形成环的边(沿 blocked-by 边做可达性检查)。
|
||||
AddDependency(ctx context.Context, c Caller, projectSlug string, in AddDependencyInput) (*Dependency, error)
|
||||
// RemoveDependency 删除一条依赖边(plan 修正用)。边不存在返回 NotFound。
|
||||
RemoveDependency(ctx context.Context, c Caller, projectSlug string, blockedNumber, blockerNumber int) error
|
||||
// ListDependencies 返回 number 指向 issue 的 (被谁阻塞 blockedBy, 阻塞了谁 blocks)。
|
||||
ListDependencies(ctx context.Context, c Caller, projectSlug string, number int) (blockedBy []*Issue, blocks []*Issue, err error)
|
||||
}
|
||||
|
||||
// ArtifactService 暴露 Requirement 阶段产物版本的应用服务方法。
|
||||
@@ -193,6 +307,46 @@ type ArtifactService interface {
|
||||
// List 的 phase 传零值("")表示返回全部阶段产物(done 阶段汇总视图用)。
|
||||
List(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase) ([]*Artifact, error)
|
||||
GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase, version int) (*Artifact, error)
|
||||
// Approve 给指定 (phase, version) 产物打 verdict(pass/fail)。verdict==pass 时
|
||||
// 同时把该阶段指针指向此产物(阶段网关据此放行后续 phase)。verdict==fail 仅
|
||||
// 落 verdict、不动指针。已关闭 requirement 拒绝批准。
|
||||
Approve(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase, version int, verdict Verdict) (*Artifact, error)
|
||||
}
|
||||
|
||||
// PhaseChangeEvent 是一次 requirement phase 流转的领域事件载荷。下游自动化
|
||||
// (如 webhook、触发 ACP run)通过 PhaseEventEmitter 订阅。
|
||||
type PhaseChangeEvent struct {
|
||||
ProjectID uuid.UUID
|
||||
ProjectSlug string
|
||||
RequirementID uuid.UUID
|
||||
RequirementNumber int
|
||||
From Phase
|
||||
To Phase
|
||||
ActorID uuid.UUID
|
||||
// OwnerID 是该需求的 owner——phase 变更通知的主要接收人(owner 关心进度,而非
|
||||
// 发起变更的 actor,后者可能是编排器/其它协作者)。
|
||||
OwnerID uuid.UUID
|
||||
}
|
||||
|
||||
// PhaseEventEmitter 把 phase 流转事件投递到通知/自动化总线。project 是领域模块,
|
||||
// 不直接 import infra/notify;由 app 层用 notify.Dispatcher 实现该窄接口。
|
||||
// 实现必须是 fire-and-forget 语义:投递失败不得阻塞 phase 流转。
|
||||
type PhaseEventEmitter interface {
|
||||
EmitPhaseChange(ctx context.Context, e PhaseChangeEvent)
|
||||
}
|
||||
|
||||
// WorkspaceStateLookup 是 Done 网关查询 workspace PR/合并状态的窄接口。由 app
|
||||
// 层用 changerequest/workspace 服务实现,避免 project 直接依赖那些模块。
|
||||
type WorkspaceStateLookup interface {
|
||||
// PullRequestMerged 报告该 requirement 是否已有合入主干的 PR(merged)。按
|
||||
// requirementID 过滤而非整个 workspace,避免同一 workspace 下其它需求的已合 PR 误判。
|
||||
PullRequestMerged(ctx context.Context, workspaceID, requirementID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// GateRunner 在 ChangePhase 写库前运行目标 phase 的所有 entry 网关,全部通过才
|
||||
// 返回 nil;任一失败返回 CodePhaseGateFailed。实现见 phase_gate.go。
|
||||
type GateRunner interface {
|
||||
Check(ctx context.Context, r *Requirement, from, to Phase) error
|
||||
}
|
||||
|
||||
// ===== 输入 DTO =====
|
||||
@@ -216,6 +370,13 @@ type UpdateProjectInput struct {
|
||||
Visibility *Visibility
|
||||
}
|
||||
|
||||
// AddMemberInput 携带新增/更新一名 project 成员所需字段。Role 必须是合法角色
|
||||
// (viewer/member/admin)。
|
||||
type AddMemberInput struct {
|
||||
UserID uuid.UUID
|
||||
Role Role
|
||||
}
|
||||
|
||||
// CreateRequirementInput 创建一条需求。OwnerID 为 nil 时由 Service 默认为调用者。
|
||||
// WorkspaceID 为 nil 表示不关联任何 workspace;非 nil 时直接落库(FK 由 PG 校验)。
|
||||
type CreateRequirementInput struct {
|
||||
@@ -257,6 +418,18 @@ type CreateIssueInput struct {
|
||||
RequirementNumber *int
|
||||
AssigneeID *uuid.UUID
|
||||
WorkspaceID *uuid.UUID
|
||||
// ParentNumber 非 nil 表示创建为该编号 issue 的子任务(任务分解)。Service 校验
|
||||
// 父 issue 与目标 project 同属。
|
||||
ParentNumber *int
|
||||
// Priority 非 nil 时设置优先级(0..3);nil 时默认 0。
|
||||
Priority *int
|
||||
}
|
||||
|
||||
// AddDependencyInput 携带新增依赖边的两端 issue 编号。语义:BlockedNumber 被
|
||||
// BlockerNumber 阻塞。两端须在同一 project(由 Service 在同 project 上下文解析保证)。
|
||||
type AddDependencyInput struct {
|
||||
BlockedNumber int
|
||||
BlockerNumber int
|
||||
}
|
||||
|
||||
// CreateArtifactInput 创建一个阶段产物版本。Version 由 Service 自动计算
|
||||
|
||||
@@ -60,6 +60,12 @@ func (h *Handler) Mount(r chi.Router) {
|
||||
r.Post("/{slug}/archive", h.archiveProject)
|
||||
r.Post("/{slug}/unarchive", h.unarchiveProject)
|
||||
|
||||
// 成员/角色 ACL(autonomy roadmap §11):list 需可读;add/remove 仅
|
||||
// project-admin 或 global-admin(service 层 assertCanManageMembers 兜底)。
|
||||
r.Get("/{slug}/members", h.listMembers)
|
||||
r.Post("/{slug}/members", h.addMember)
|
||||
r.Delete("/{slug}/members/{userID}", h.removeMember)
|
||||
|
||||
r.Post("/{slug}/requirements", h.createRequirement)
|
||||
r.Get("/{slug}/requirements", h.listRequirements)
|
||||
r.Get("/{slug}/requirements/{number}", h.getRequirement)
|
||||
@@ -70,6 +76,7 @@ func (h *Handler) Mount(r chi.Router) {
|
||||
r.Post("/{slug}/requirements/{number}/artifacts", h.createArtifact)
|
||||
r.Get("/{slug}/requirements/{number}/artifacts", h.listArtifacts)
|
||||
r.Get("/{slug}/requirements/{number}/artifacts/{phase}/{version}", h.getArtifact)
|
||||
r.Post("/{slug}/requirements/{number}/artifacts/{phase}/{version}/approve", h.approveArtifact)
|
||||
|
||||
r.Post("/{slug}/issues", h.createIssue)
|
||||
r.Get("/{slug}/issues", h.listIssues)
|
||||
@@ -171,6 +178,29 @@ type artifactDTO struct {
|
||||
SourceMessageID *int64 `json:"source_message_id,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
type memberDTO struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
AddedBy *string `json:"added_by,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toMemberDTO(m *Member) memberDTO {
|
||||
d := memberDTO{
|
||||
ProjectID: m.ProjectID.String(),
|
||||
UserID: m.UserID.String(),
|
||||
Role: string(m.Role),
|
||||
CreatedAt: m.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
if m.AddedBy != nil {
|
||||
s := m.AddedBy.String()
|
||||
d.AddedBy = &s
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func toProjectDTO(p *Project) projectDTO {
|
||||
@@ -240,6 +270,7 @@ func toArtifactDTO(a *Artifact) artifactDTO {
|
||||
SourceMessageID: a.SourceMessageID,
|
||||
CreatedBy: a.CreatedBy.String(),
|
||||
CreatedAt: a.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
Verdict: string(a.Verdict),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +395,75 @@ func (h *Handler) toggleArchive(w http.ResponseWriter, r *http.Request, archive
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ===== Member endpoints(autonomy roadmap §11)=====
|
||||
|
||||
type addMemberReq struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (h *Handler) listMembers(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.callerFromCtx(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
out, err := h.projects.ListMembers(r.Context(), c, chi.URLParam(r, "slug"))
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
dtos := make([]memberDTO, 0, len(out))
|
||||
for _, m := range out {
|
||||
dtos = append(dtos, toMemberDTO(m))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
|
||||
}
|
||||
|
||||
func (h *Handler) addMember(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.callerFromCtx(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
var req addMemberReq
|
||||
if err := decodeBody(r, &req); err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
uid, err := uuid.Parse(req.UserID)
|
||||
if err != nil {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 非法"))
|
||||
return
|
||||
}
|
||||
out, err := h.projects.AddMember(r.Context(), c, chi.URLParam(r, "slug"), AddMemberInput{
|
||||
UserID: uid, Role: Role(req.Role),
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, toMemberDTO(out))
|
||||
}
|
||||
|
||||
func (h *Handler) removeMember(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.callerFromCtx(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
uid, err := uuid.Parse(chi.URLParam(r, "userID"))
|
||||
if err != nil {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 非法"))
|
||||
return
|
||||
}
|
||||
if err := h.projects.RemoveMember(r.Context(), c, chi.URLParam(r, "slug"), uid); err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ===== Requirement endpoints =====
|
||||
|
||||
type createRequirementReq struct {
|
||||
@@ -672,6 +772,40 @@ func (h *Handler) getArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out))
|
||||
}
|
||||
|
||||
type approveArtifactReq struct {
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
func (h *Handler) approveArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.callerFromCtx(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
num, err := numberParam(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
ver, err := strconv.Atoi(chi.URLParam(r, "version"))
|
||||
if err != nil || ver <= 0 {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法"))
|
||||
return
|
||||
}
|
||||
var req approveArtifactReq
|
||||
if err := decodeBody(r, &req); err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
phase := Phase(chi.URLParam(r, "phase"))
|
||||
out, err := h.artifacts.Approve(r.Context(), c, chi.URLParam(r, "slug"), num, phase, ver, Verdict(req.Verdict))
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out))
|
||||
}
|
||||
|
||||
// ===== Issue endpoints =====
|
||||
|
||||
type createIssueReq struct {
|
||||
|
||||
@@ -32,7 +32,8 @@ func mountFullHandler(t *testing.T, ownerToken string, ownerID uuid.UUID) (*fake
|
||||
t.Helper()
|
||||
repo := newFakeRepo()
|
||||
pSvc := NewProjectService(repo, nil)
|
||||
rSvc := NewRequirementService(repo, nil)
|
||||
gate := NewGateRunner(GateDeps{Repo: repo, Workspace: nil}, nil)
|
||||
rSvc := NewRequirementService(repo, nil, nil, gate)
|
||||
iSvc := NewIssueService(repo, nil)
|
||||
artSvc := NewArtifactService(repo, nil)
|
||||
resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}}
|
||||
@@ -235,9 +236,9 @@ func TestHandler_Artifacts_Routes(t *testing.T) {
|
||||
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok,
|
||||
map[string]any{"phase": "auditing", "content": "# audit"}, http.StatusCreated)
|
||||
|
||||
// 非法 phase → 400
|
||||
// 非法 phase(done 不在 ArtifactPhases)→ 400
|
||||
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok,
|
||||
map[string]any{"phase": "implementing", "content": "x"}, http.StatusBadRequest)
|
||||
map[string]any{"phase": "done", "content": "x"}, http.StatusBadRequest)
|
||||
|
||||
// list 裸数组:全量 2 条;?phase=planning 过滤 1 条
|
||||
respList := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts", tok, nil)
|
||||
@@ -265,3 +266,30 @@ func TestHandler_Artifacts_Routes(t *testing.T) {
|
||||
checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/bogus/1", tok, nil, http.StatusBadRequest)
|
||||
checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/planning/9", tok, nil, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// TestHandler_ApproveArtifactAndGatedPhase 验证 approve 端点回写 verdict,且未满足
|
||||
// gate 的 phase 流转返回 409 phase_gate_failed,批准后放行 200。
|
||||
func TestHandler_ApproveArtifactAndGatedPhase(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)
|
||||
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R"}, http.StatusCreated)
|
||||
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok,
|
||||
map[string]any{"phase": "auditing", "content": "# audit"}, http.StatusCreated)
|
||||
|
||||
// 未批准 auditing 产物时流转到 implementing → 409 phase_gate_failed
|
||||
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/phase", tok,
|
||||
map[string]any{"phase": "implementing"}, http.StatusConflict)
|
||||
|
||||
// approve 端点回写 verdict
|
||||
respApprove := doJSON(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts/auditing/1/approve", tok,
|
||||
map[string]any{"verdict": "pass"})
|
||||
require.Equal(t, http.StatusOK, respApprove.StatusCode)
|
||||
approved := decodeBodyMap(t, respApprove)
|
||||
require.Equal(t, "pass", approved["verdict"])
|
||||
|
||||
// 批准后流转放行 → 200
|
||||
checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/phase", tok,
|
||||
map[string]any{"phase": "implementing"}, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -59,11 +59,20 @@ func (s *issueService) Create(ctx context.Context, c Caller, slug string, in Cre
|
||||
if r != nil {
|
||||
reqID = &r.ID
|
||||
}
|
||||
parentID, err := s.resolveParent(ctx, p, in.ParentNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prio, err := normalizePriority(in.Priority)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
ParentID: parentID, Priority: prio,
|
||||
}
|
||||
out, err := s.createWithRetry(ctx, in1)
|
||||
if err != nil {
|
||||
@@ -76,10 +85,38 @@ func (s *issueService) Create(ctx context.Context, c Caller, slug string, in Cre
|
||||
if in.WorkspaceID != nil {
|
||||
meta["workspace_id"] = in.WorkspaceID.String()
|
||||
}
|
||||
if parentID != nil {
|
||||
meta["parent_id"] = parentID.String()
|
||||
}
|
||||
s.recordAudit(ctx, c, "issue.create", out.ID.String(), meta)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveParent 把 *number 解析为父 issue 的 *id,并校验同 project。number == nil →
|
||||
// (nil, nil)。父 issue 不存在 → NotFound。
|
||||
func (s *issueService) resolveParent(ctx context.Context, p *Project, number *int) (*uuid.UUID, error) {
|
||||
if number == nil {
|
||||
return nil, nil
|
||||
}
|
||||
parent, err := s.repo.GetIssueByNumber(ctx, p.ID, *number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := parent.ID
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// normalizePriority 校验 priority 在 0..3,nil → 0(默认)。
|
||||
func normalizePriority(p *int) (int, error) {
|
||||
if p == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if *p < 0 || *p > 3 {
|
||||
return 0, errs.New(errs.CodeInvalidInput, "priority 必须在 0..3")
|
||||
}
|
||||
return *p, nil
|
||||
}
|
||||
|
||||
func (s *issueService) createWithRetry(ctx context.Context, in *Issue) (*Issue, error) {
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
out, err := s.repo.CreateIssue(ctx, in)
|
||||
@@ -267,6 +304,137 @@ func (s *issueService) Reopen(ctx context.Context, c Caller, slug string, number
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== 任务分解 / 依赖图(autonomy roadmap §10)=====
|
||||
|
||||
// CreateSubtask 在 parentNumber 指向的 issue 下创建子任务。复用 Create 的校验/重试/审计,
|
||||
// 把 parentNumber 注入 CreateIssueInput.ParentNumber。
|
||||
func (s *issueService) CreateSubtask(ctx context.Context, c Caller, slug string, parentNumber int, in CreateIssueInput) (*Issue, error) {
|
||||
pn := parentNumber
|
||||
in.ParentNumber = &pn
|
||||
out, err := s.Create(ctx, c, slug, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordAudit(ctx, c, "issue.create_subtask", out.ID.String(),
|
||||
map[string]any{"parent_number": parentNumber, "number": out.Number})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddDependency 新增依赖边:blocked 被 blocker 阻塞。拒绝自依赖、跨 project、重复、成环。
|
||||
func (s *issueService) AddDependency(ctx context.Context, c Caller, slug string, in AddDependencyInput) (*Dependency, error) {
|
||||
if in.BlockedNumber == in.BlockerNumber {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "issue 不能依赖自身")
|
||||
}
|
||||
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocked, err := s.repo.GetIssueByNumber(ctx, p.ID, in.BlockedNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocker, err := s.repo.GetIssueByNumber(ctx, p.ID, in.BlockerNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 环检查:新增 "blocked blocked-by blocker" 成环,当且仅当 blocked 已能沿 blocked-by
|
||||
// 边到达 blocker(即 blocker 已(传递地)依赖 blocked)。从 blocker 出发遍历其
|
||||
// blocker 集合,若到达 blocked 则成环。
|
||||
cyclic, err := s.reachable(ctx, blocker.ID, blocked.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cyclic {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "新增依赖会形成环")
|
||||
}
|
||||
dep := &Dependency{
|
||||
ID: uuid.New(), ProjectID: p.ID,
|
||||
BlockedID: blocked.ID, BlockerID: blocker.ID, CreatedBy: c.UserID,
|
||||
}
|
||||
out, err := s.repo.CreateDependency(ctx, dep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordAudit(ctx, c, "issue.add_dependency", out.ID.String(),
|
||||
map[string]any{"blocked_number": in.BlockedNumber, "blocker_number": in.BlockerNumber})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// reachable 报告从 start 出发沿 blocked-by 边(start 的 blocker、blocker 的 blocker …)
|
||||
// 是否能到达 target。迭代 BFS + visited 防环死循环。
|
||||
func (s *issueService) reachable(ctx context.Context, start, target uuid.UUID) (bool, error) {
|
||||
if start == target {
|
||||
return true, nil
|
||||
}
|
||||
visited := map[uuid.UUID]bool{start: true}
|
||||
queue := []uuid.UUID{start}
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
blockers, err := s.repo.ListBlockerIDs(ctx, cur)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, b := range blockers {
|
||||
if b == target {
|
||||
return true, nil
|
||||
}
|
||||
if !visited[b] {
|
||||
visited[b] = true
|
||||
queue = append(queue, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// RemoveDependency 删除一条依赖边。边不存在返回 NotFound。
|
||||
func (s *issueService) RemoveDependency(ctx context.Context, c Caller, slug string, blockedNumber, blockerNumber int) error {
|
||||
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blocked, err := s.repo.GetIssueByNumber(ctx, p.ID, blockedNumber)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blocker, err := s.repo.GetIssueByNumber(ctx, p.ID, blockerNumber)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := s.repo.DeleteDependency(ctx, p.ID, blocked.ID, blocker.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return errs.New(errs.CodeNotFound, "依赖不存在")
|
||||
}
|
||||
s.recordAudit(ctx, c, "issue.remove_dependency", blocked.ID.String(),
|
||||
map[string]any{"blocked_number": blockedNumber, "blocker_number": blockerNumber})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDependencies 返回 (blockedBy: 阻塞本 issue 的 issues, blocks: 被本 issue 阻塞的 issues)。
|
||||
func (s *issueService) ListDependencies(ctx context.Context, c Caller, slug string, number int) ([]*Issue, []*Issue, error) {
|
||||
p, err := loadProjectForRead(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
iss, err := s.repo.GetIssueByNumber(ctx, p.ID, number)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
blockedBy, err := s.repo.ListBlockers(ctx, iss.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
blocks, err := s.repo.ListBlocked(ctx, iss.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return blockedBy, blocks, nil
|
||||
}
|
||||
|
||||
func (s *issueService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func newIssueSvc(repo *fakeRepo) (IssueService, RequirementService, *spyAudit) {
|
||||
spy := &spyAudit{}
|
||||
return NewIssueService(repo, spy), NewRequirementService(repo, spy), spy
|
||||
return NewIssueService(repo, spy), NewRequirementService(repo, spy, nil, nil), spy
|
||||
}
|
||||
|
||||
func TestIssueService_Create_FreelanceAndAttached(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// 成员/角色 ACL(autonomy roadmap §11)测试。证明:member(editor) 可写、viewer
|
||||
// 只读、非成员对 private 仍被拒、owner/admin 行为不变、project-admin 可管理成员、
|
||||
// 跨项目成员隔离。
|
||||
|
||||
func TestMembers_ViewerCanReadNotWrite(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
viewer := uuid.New()
|
||||
p := seedProject(t, repo, owner) // private
|
||||
_, err := repo.UpsertMember(context.Background(), p.ID, viewer, RoleViewer, &owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewProjectService(repo, nil)
|
||||
c := Caller{UserID: viewer}
|
||||
|
||||
// 读:viewer 可读 private 项目(成员叠加读权限)。
|
||||
got, err := svc.Get(context.Background(), c, "demo")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "demo", got.Slug)
|
||||
|
||||
// 写:viewer 不可写。能读但不能写 → forbidden(其存在性已可见)。
|
||||
name := "Renamed"
|
||||
_, err = svc.Update(context.Background(), c, "demo", UpdateProjectInput{Name: &name})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeForbidden, ae.Code)
|
||||
}
|
||||
|
||||
func TestMembers_EditorCanWriteRequirements(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
editor := uuid.New()
|
||||
p := seedProject(t, repo, owner) // private
|
||||
_, err := repo.UpsertMember(context.Background(), p.ID, editor, RoleMember, &owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
reqSvc := NewRequirementService(repo, nil, nil, nil)
|
||||
c := Caller{UserID: editor}
|
||||
|
||||
// member 角色叠加写权限:可在 private 项目创建需求。
|
||||
got, err := reqSvc.Create(context.Background(), c, "demo", CreateRequirementInput{Title: "feat"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "feat", got.Title)
|
||||
require.Equal(t, editor, got.OwnerID, "默认 owner 为创建者")
|
||||
|
||||
// member 也可读。
|
||||
_, err = reqSvc.Get(context.Background(), c, "demo", got.Number)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMembers_ViewerCannotWriteRequirements(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
viewer := uuid.New()
|
||||
p := seedProject(t, repo, owner)
|
||||
_, err := repo.UpsertMember(context.Background(), p.ID, viewer, RoleViewer, &owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
reqSvc := NewRequirementService(repo, nil, nil, nil)
|
||||
_, err = reqSvc.Create(context.Background(), Caller{UserID: viewer}, "demo", CreateRequirementInput{Title: "x"})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeForbidden, ae.Code, "viewer 能读但不能写 → forbidden")
|
||||
|
||||
// viewer 仍可读需求列表。
|
||||
_, err = reqSvc.List(context.Background(), Caller{UserID: viewer}, "demo", RequirementFilter{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMembers_NonMemberDeniedOnPrivate(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner) // private, 无其它成员
|
||||
svc := NewProjectService(repo, nil)
|
||||
|
||||
stranger := Caller{UserID: uuid.New()}
|
||||
// 非成员看 private:屏蔽存在性 → not_found(既有防探测语义保持)。
|
||||
_, err := svc.Get(context.Background(), stranger, "demo")
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
|
||||
// 非成员写:同样 not_found(既有语义)。
|
||||
name := "X"
|
||||
_, err = svc.Update(context.Background(), stranger, "demo", UpdateProjectInput{Name: &name})
|
||||
ae, ok = errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
}
|
||||
|
||||
func TestMembers_OwnerAndAdminUnchanged(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner) // private, 无 project_members 行(模拟未回填或被移除)
|
||||
svc := NewProjectService(repo, nil)
|
||||
|
||||
// owner 即便没有 project_members 行也可读写(OwnerID 兜底)。
|
||||
name := "ByOwner"
|
||||
_, err := svc.Update(context.Background(), Caller{UserID: owner}, "demo", UpdateProjectInput{Name: &name})
|
||||
require.NoError(t, err)
|
||||
|
||||
// global-admin 旁路:可读写任意 private 项目。
|
||||
name2 := "ByAdmin"
|
||||
_, err = svc.Update(context.Background(), Caller{UserID: uuid.New(), IsAdmin: true}, "demo", UpdateProjectInput{Name: &name2})
|
||||
require.NoError(t, err)
|
||||
got, err := svc.Get(context.Background(), Caller{UserID: uuid.New(), IsAdmin: true}, "demo")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ByAdmin", got.Name)
|
||||
}
|
||||
|
||||
func TestMembers_ProjectAdminCanManageMembers(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
padmin := uuid.New()
|
||||
target := uuid.New()
|
||||
p := seedProject(t, repo, owner)
|
||||
_, err := repo.UpsertMember(context.Background(), p.ID, padmin, RoleAdmin, &owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewProjectService(repo, nil)
|
||||
cAdmin := Caller{UserID: padmin}
|
||||
|
||||
// project-admin 可新增成员。
|
||||
m, err := svc.AddMember(context.Background(), cAdmin, "demo", AddMemberInput{UserID: target, Role: RoleMember})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RoleMember, m.Role)
|
||||
|
||||
// project-admin 可改角色(upsert)。
|
||||
m, err = svc.AddMember(context.Background(), cAdmin, "demo", AddMemberInput{UserID: target, Role: RoleViewer})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RoleViewer, m.Role)
|
||||
|
||||
// project-admin 可列成员。
|
||||
members, err := svc.ListMembers(context.Background(), cAdmin, "demo")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, members)
|
||||
|
||||
// project-admin 可移除成员。
|
||||
require.NoError(t, svc.RemoveMember(context.Background(), cAdmin, "demo", target))
|
||||
role, err := repo.GetMemberRole(context.Background(), p.ID, target)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RoleNone, role)
|
||||
|
||||
// 不能移除 owner。
|
||||
err = svc.RemoveMember(context.Background(), cAdmin, "demo", owner)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeForbidden, ae.Code)
|
||||
}
|
||||
|
||||
func TestMembers_NonAdminCannotManageMembers(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
editor := uuid.New()
|
||||
p := seedProject(t, repo, owner)
|
||||
_, err := repo.UpsertMember(context.Background(), p.ID, editor, RoleMember, &owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewProjectService(repo, nil)
|
||||
// member(editor) 不可管理成员 → forbidden(能读,但无管理权)。
|
||||
_, err = svc.AddMember(context.Background(), Caller{UserID: editor}, "demo", AddMemberInput{UserID: uuid.New(), Role: RoleViewer})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeForbidden, ae.Code)
|
||||
|
||||
// 非成员对 private 管理成员 → not_found 防探测。
|
||||
_, err = svc.AddMember(context.Background(), Caller{UserID: uuid.New()}, "demo", AddMemberInput{UserID: uuid.New(), Role: RoleViewer})
|
||||
ae, ok = errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
}
|
||||
|
||||
func TestMembers_AddMemberValidatesInput(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc := NewProjectService(repo, nil)
|
||||
|
||||
// 非法角色被拒。
|
||||
_, err := svc.AddMember(context.Background(), Caller{UserID: owner}, "demo", AddMemberInput{UserID: uuid.New(), Role: Role("superuser")})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
|
||||
// 空 user_id 被拒。
|
||||
_, err = svc.AddMember(context.Background(), Caller{UserID: owner}, "demo", AddMemberInput{UserID: uuid.Nil, Role: RoleViewer})
|
||||
ae, ok = errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestMembers_CrossProjectIsolation(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
ownerA := uuid.New()
|
||||
ownerB := uuid.New()
|
||||
pa := seedProject(t, repo, ownerA) // slug "demo"
|
||||
|
||||
pb := &Project{ID: uuid.New(), Slug: "other", Name: "Other", Visibility: VisibilityPrivate, OwnerID: ownerB}
|
||||
_, err := repo.CreateProject(context.Background(), pb)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 用户是 A 项目的 member,但不是 B 项目成员。
|
||||
member := uuid.New()
|
||||
_, err = repo.UpsertMember(context.Background(), pa.ID, member, RoleMember, &ownerA)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewProjectService(repo, nil)
|
||||
|
||||
// A 项目可写。
|
||||
nameA := "A-renamed"
|
||||
_, err = svc.Update(context.Background(), Caller{UserID: member}, "demo", UpdateProjectInput{Name: &nameA})
|
||||
require.NoError(t, err)
|
||||
|
||||
// B 项目(private,非成员)→ not_found。
|
||||
nameB := "B-renamed"
|
||||
_, err = svc.Update(context.Background(), Caller{UserID: member}, "other", UpdateProjectInput{Name: &nameB})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeNotFound, ae.Code)
|
||||
}
|
||||
|
||||
func TestMembers_ListIncludesMembershipProjects(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
pa := seedProject(t, repo, owner) // private "demo"
|
||||
|
||||
viewer := uuid.New()
|
||||
_, err := repo.UpsertMember(context.Background(), pa.ID, viewer, RoleViewer, &owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewProjectService(repo, nil)
|
||||
// viewer 列项目应包含其作为成员的 private 项目。
|
||||
got, err := svc.List(context.Background(), Caller{UserID: viewer}, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, "demo", got[0].Slug)
|
||||
|
||||
// 完全无关用户列不到该 private 项目。
|
||||
got, err = svc.List(context.Background(), Caller{UserID: uuid.New()}, false)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package project 内的 phase_gate.go 实现"阶段状态机网关":把原来 ChangePhase
|
||||
// 的 any-to-any 替换为按目标 phase 声明 entry 网关的谓词引擎。
|
||||
//
|
||||
// 设计取舍:
|
||||
// - 网关只在"流转进入"目标 phase 时运行,不校验已有状态——历史上已处于
|
||||
// implementing/done 的需求(迁移前数据)天然被豁免,无需数据迁移。
|
||||
// - 未声明网关的目标 phase(planning/prototyping/auditing/reviewing)保持
|
||||
// any-to-any 旧行为(空网关列表 = 永远放行)。
|
||||
// - 网关依赖通过 GateDeps 注入(Repo 查阶段指针、Workspace 查 PR 合并状态),
|
||||
// 便于单测用 fake 覆盖;Done 网关在 Workspace 缺失时按"未合并"优雅降级。
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// PhaseGate 是单个 entry 谓词:返回 nil 表示 from->to 对 r 允许。
|
||||
type PhaseGate interface {
|
||||
Check(ctx context.Context, dep GateDeps, r *Requirement, from, to Phase) error
|
||||
}
|
||||
|
||||
// GateDeps 是网关运行所需的外部依赖。Workspace 可为 nil(Done 网关据此降级为
|
||||
// "未合并",从而阻止流转——更安全的默认)。
|
||||
type GateDeps struct {
|
||||
Repo Repository
|
||||
Workspace WorkspaceStateLookup
|
||||
}
|
||||
|
||||
// phaseGates 是 target phase -> entry 网关列表的转换表。只有 implementing/done
|
||||
// 声明网关;其余 phase 列表为空(保持 any-to-any)。
|
||||
//
|
||||
// implementing: 需要 auditing 阶段有 verdict=pass 的已批准产物(阶段指针就绪)
|
||||
// done: 需要 requirement 关联 workspace 且其 PR 已合并
|
||||
var phaseGates = map[Phase][]PhaseGate{
|
||||
PhaseImplementing: {requireApprovedArtifact(PhaseAuditing)},
|
||||
PhaseDone: {requireWorkspaceMerged{}},
|
||||
}
|
||||
|
||||
// gateRunner 是 GateRunner 的默认实现:解析 to 的网关列表并逐个运行。
|
||||
type gateRunner struct {
|
||||
gates map[Phase][]PhaseGate
|
||||
dep GateDeps
|
||||
}
|
||||
|
||||
// NewGateRunner 用转换表与依赖构造 GateRunner。gates 传 nil 时用包内默认表。
|
||||
func NewGateRunner(dep GateDeps, gates map[Phase][]PhaseGate) GateRunner {
|
||||
if gates == nil {
|
||||
gates = phaseGates
|
||||
}
|
||||
return &gateRunner{gates: gates, dep: dep}
|
||||
}
|
||||
|
||||
// Check 运行目标 phase 的全部 entry 网关,遇首个失败即返回。
|
||||
func (g *gateRunner) Check(ctx context.Context, r *Requirement, from, to Phase) error {
|
||||
for _, gate := range g.gates[to] {
|
||||
if err := gate.Check(ctx, g.dep, r, from, to); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireApprovedArtifact 网关:命名 phase 必须存在已批准(指针非空)且其产物
|
||||
// verdict==pass 的产物。implementing 据此要求 auditing 报告先过审。
|
||||
type requireApprovedArtifact Phase
|
||||
|
||||
func (g requireApprovedArtifact) Check(ctx context.Context, dep GateDeps, r *Requirement, _, to Phase) error {
|
||||
gatedPhase := Phase(g)
|
||||
ptr, err := dep.Repo.GetPhasePointer(ctx, r.ID, gatedPhase)
|
||||
if err != nil {
|
||||
if ae, ok := errs.As(err); ok && ae.Code == errs.CodeNotFound {
|
||||
return errs.New(errs.CodePhaseGateFailed,
|
||||
"流转到 "+string(to)+" 前需先批准 "+string(gatedPhase)+" 阶段产物(verdict=pass)")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ptr.ApprovedArtifactID == nil {
|
||||
return errs.New(errs.CodePhaseGateFailed,
|
||||
"流转到 "+string(to)+" 前需先批准 "+string(gatedPhase)+" 阶段产物(verdict=pass)")
|
||||
}
|
||||
art, err := dep.Repo.GetArtifactByID(ctx, *ptr.ApprovedArtifactID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if art.Verdict != VerdictPass {
|
||||
return errs.New(errs.CodePhaseGateFailed,
|
||||
"流转到 "+string(to)+" 前 "+string(gatedPhase)+" 阶段已批准产物的 verdict 必须为 pass")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireWorkspaceMerged 网关:requirement 必须关联 workspace,且该 workspace 的
|
||||
// PR 已合并。Workspace 依赖缺失或查询失败时优雅降级为"未合并"(阻止流转)。
|
||||
type requireWorkspaceMerged struct{}
|
||||
|
||||
func (requireWorkspaceMerged) Check(ctx context.Context, dep GateDeps, r *Requirement, _, to Phase) error {
|
||||
if r.WorkspaceID == nil {
|
||||
return errs.New(errs.CodePhaseGateFailed,
|
||||
"流转到 "+string(to)+" 前需先关联 workspace 并合入其 PR")
|
||||
}
|
||||
if dep.Workspace == nil {
|
||||
return errs.New(errs.CodePhaseGateFailed,
|
||||
"无法确认 workspace PR 合并状态,暂不允许流转到 "+string(to))
|
||||
}
|
||||
merged, err := dep.Workspace.PullRequestMerged(ctx, *r.WorkspaceID, r.ID)
|
||||
if err != nil {
|
||||
return errs.Wrap(err, errs.CodePhaseGateFailed, "查询 workspace PR 合并状态失败")
|
||||
}
|
||||
if !merged {
|
||||
return errs.New(errs.CodePhaseGateFailed,
|
||||
"流转到 "+string(to)+" 前需先合入 workspace 的 PR")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// fakeWorkspaceState is a controllable WorkspaceStateLookup for Done-gate tests.
|
||||
type fakeWorkspaceState struct {
|
||||
merged bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeWorkspaceState) PullRequestMerged(_ context.Context, _, _ uuid.UUID) (bool, error) {
|
||||
return f.merged, f.err
|
||||
}
|
||||
|
||||
// seedRequirement creates an open requirement under the seeded "demo" project.
|
||||
func seedRequirement(t *testing.T, repo *fakeRepo, owner uuid.UUID, ws *uuid.UUID) *Requirement {
|
||||
t.Helper()
|
||||
reqSvc, _, _ := newReqSvc(repo)
|
||||
r, err := reqSvc.Create(context.Background(), Caller{UserID: owner}, "demo", CreateRequirementInput{
|
||||
Title: "R", WorkspaceID: ws,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
|
||||
func requireGateFailed(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodePhaseGateFailed, ae.Code)
|
||||
}
|
||||
|
||||
func TestPhaseGate_Implementing_BlockedWithoutApprovedAuditing(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
r := seedRequirement(t, repo, owner, nil)
|
||||
|
||||
gate := NewGateRunner(GateDeps{Repo: repo}, nil)
|
||||
err := gate.Check(context.Background(), r, PhasePlanning, PhaseImplementing)
|
||||
requireGateFailed(t, err)
|
||||
}
|
||||
|
||||
func TestPhaseGate_Implementing_AllowedAfterApproval(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, _, _, _ := newArtifactSvc(repo)
|
||||
r := seedRequirement(t, repo, owner, nil)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
_, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"})
|
||||
require.NoError(t, err)
|
||||
_, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass)
|
||||
require.NoError(t, err)
|
||||
|
||||
gate := NewGateRunner(GateDeps{Repo: repo}, nil)
|
||||
require.NoError(t, gate.Check(context.Background(), r, PhasePlanning, PhaseImplementing))
|
||||
}
|
||||
|
||||
func TestPhaseGate_Implementing_BlockedWhenVerdictFail(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
artSvc, _, _, _ := newArtifactSvc(repo)
|
||||
r := seedRequirement(t, repo, owner, nil)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
_, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"})
|
||||
require.NoError(t, err)
|
||||
// verdict=fail 不写指针,gate 仍应阻止。
|
||||
_, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictFail)
|
||||
require.NoError(t, err)
|
||||
|
||||
gate := NewGateRunner(GateDeps{Repo: repo}, nil)
|
||||
requireGateFailed(t, gate.Check(context.Background(), r, PhasePlanning, PhaseImplementing))
|
||||
}
|
||||
|
||||
func TestPhaseGate_Done_BlockedWhenNoWorkspace(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
r := seedRequirement(t, repo, owner, nil) // no workspace
|
||||
|
||||
gate := NewGateRunner(GateDeps{Repo: repo, Workspace: fakeWorkspaceState{merged: true}}, nil)
|
||||
requireGateFailed(t, gate.Check(context.Background(), r, PhaseReviewing, PhaseDone))
|
||||
}
|
||||
|
||||
func TestPhaseGate_Done_BlockedWhenNotMerged(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
ws := uuid.New()
|
||||
r := seedRequirement(t, repo, owner, &ws)
|
||||
|
||||
gate := NewGateRunner(GateDeps{Repo: repo, Workspace: fakeWorkspaceState{merged: false}}, nil)
|
||||
requireGateFailed(t, gate.Check(context.Background(), r, PhaseReviewing, PhaseDone))
|
||||
}
|
||||
|
||||
func TestPhaseGate_Done_AllowedWhenMerged(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
ws := uuid.New()
|
||||
r := seedRequirement(t, repo, owner, &ws)
|
||||
|
||||
gate := NewGateRunner(GateDeps{Repo: repo, Workspace: fakeWorkspaceState{merged: true}}, nil)
|
||||
require.NoError(t, gate.Check(context.Background(), r, PhaseReviewing, PhaseDone))
|
||||
}
|
||||
|
||||
func TestPhaseGate_Done_BlockedWhenWorkspaceLookupNil(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
ws := uuid.New()
|
||||
r := seedRequirement(t, repo, owner, &ws)
|
||||
|
||||
// Workspace 缺失 → 优雅降级为"未合并",阻止流转。
|
||||
gate := NewGateRunner(GateDeps{Repo: repo, Workspace: nil}, nil)
|
||||
requireGateFailed(t, gate.Check(context.Background(), r, PhaseReviewing, PhaseDone))
|
||||
}
|
||||
|
||||
func TestPhaseGate_OpenPhases_AlwaysAllowed(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
r := seedRequirement(t, repo, owner, nil)
|
||||
|
||||
gate := NewGateRunner(GateDeps{Repo: repo}, nil)
|
||||
// planning/prototyping/auditing/reviewing 无 entry 网关 → 任意流转放行(回归 any-to-any)。
|
||||
for _, to := range []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing, PhaseReviewing} {
|
||||
require.NoError(t, gate.Check(context.Background(), r, PhasePlanning, to), "to=%s", to)
|
||||
}
|
||||
}
|
||||
@@ -30,24 +30,39 @@ func NewProjectService(repo Repository, rec audit.Recorder) ProjectService {
|
||||
return &projectService{repo: repo, audit: rec}
|
||||
}
|
||||
|
||||
// assertReadable 是 read 类操作的鉴权门面:private 仅 owner+admin,internal
|
||||
// 任意登录用户。失败统一返回 CodeNotFound(避免存在性探测)。
|
||||
func assertReadable(p *Project, c Caller) error {
|
||||
// assertReadable 是 read 类操作的鉴权门面。成员/角色 ACL(autonomy roadmap §11)
|
||||
// 在既有规则之上"叠加"成员授权(只增不减):
|
||||
// - internal 可见性:任意登录用户可读(既有)
|
||||
// - owner / global-admin:可读(既有)
|
||||
// - 任何成员角色(viewer/member/admin):可读(新增叠加)
|
||||
//
|
||||
// 失败统一返回 CodeNotFound(避免存在性探测)。role 由 service 在调用前查得,
|
||||
// 非成员传 RoleNone。
|
||||
func assertReadable(p *Project, c Caller, role Role) error {
|
||||
if p.Visibility == VisibilityInternal {
|
||||
return nil
|
||||
}
|
||||
if c.IsAdmin || p.OwnerID == c.UserID {
|
||||
return nil
|
||||
}
|
||||
if role.CanRead() {
|
||||
return nil
|
||||
}
|
||||
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||
}
|
||||
|
||||
// assertWritable 是 write 类操作的鉴权门面:仅 owner+admin。已归档项目额外
|
||||
// 拒绝;调用方应在执行写入前调用此函数。
|
||||
func assertWritable(p *Project, c Caller) error {
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID {
|
||||
// stranger 对 private 看不见;对 internal 也不能写。统一 not_found 防探测。
|
||||
if p.Visibility == VisibilityPrivate {
|
||||
// assertWritable 是 write 类操作的鉴权门面。成员/角色 ACL 叠加写授权:
|
||||
// - owner / global-admin:可写(既有)
|
||||
// - member / admin 成员角色:可写(新增叠加)
|
||||
// - viewer / 非成员:不可写
|
||||
//
|
||||
// 已归档项目额外拒绝;调用方应在执行写入前调用此函数。role 由 service 查得。
|
||||
func assertWritable(p *Project, c Caller, role Role) error {
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID && !role.CanWrite() {
|
||||
// stranger 对 private 看不见;对 internal/viewer 也不能写。统一 not_found
|
||||
// 防探测——但若 caller 至少能读(internal 可见性或 viewer 成员),暴露
|
||||
// forbidden 比 not_found 更准确(其存在性本就可见)。
|
||||
if p.Visibility == VisibilityPrivate && !role.CanRead() {
|
||||
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||
}
|
||||
return errs.New(errs.CodeForbidden, "无权修改该项目")
|
||||
@@ -58,6 +73,21 @@ func assertWritable(p *Project, c Caller) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// memberRoleFor 查 caller 在 project 的成员角色。owner/global-admin 隐式视为
|
||||
// RoleAdmin(与回填一致,避免对 owner 多查一次也无妨——这里仍走 DB 但 owner
|
||||
// 经迁移已有 admin 行)。查询失败按 RoleNone 处理(best-effort,既有 owner/admin
|
||||
// 规则仍兜底)。
|
||||
func (s *projectService) memberRoleFor(ctx context.Context, p *Project, c Caller) Role {
|
||||
if c.IsAdmin || p.OwnerID == c.UserID {
|
||||
return RoleAdmin
|
||||
}
|
||||
role, err := s.repo.GetMemberRole(ctx, p.ID, c.UserID)
|
||||
if err != nil {
|
||||
return RoleNone
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
func (s *projectService) Create(ctx context.Context, c Caller, in CreateProjectInput) (*Project, error) {
|
||||
if in.Slug == "" || in.Name == "" {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "slug / name 必填")
|
||||
@@ -87,7 +117,7 @@ func (s *projectService) Get(ctx context.Context, c Caller, slug string) (*Proje
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertReadable(p, c); err != nil {
|
||||
if err := assertReadable(p, c, s.memberRoleFor(ctx, p, c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
@@ -100,7 +130,7 @@ func (s *projectService) GetByID(ctx context.Context, c Caller, id uuid.UUID) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertReadable(p, c); err != nil {
|
||||
if err := assertReadable(p, c, s.memberRoleFor(ctx, p, c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
@@ -113,7 +143,13 @@ func (s *projectService) List(ctx context.Context, c Caller, includeArchived boo
|
||||
}
|
||||
out := make([]*Project, 0, len(all))
|
||||
for _, p := range all {
|
||||
if assertReadable(p, c) == nil {
|
||||
// 列表场景:internal/owner/admin 命中即收,避免对每个 private 项目都查一次
|
||||
// 成员表;仅当上述既有规则不命中时才回退查成员角色(叠加 viewer/member)。
|
||||
if assertReadable(p, c, RoleNone) == nil {
|
||||
out = append(out, p)
|
||||
continue
|
||||
}
|
||||
if role := s.memberRoleFor(ctx, p, c); assertReadable(p, c, role) == nil {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
@@ -125,7 +161,7 @@ func (s *projectService) Update(ctx context.Context, c Caller, slug string, in U
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertWritable(p, c); err != nil {
|
||||
if err := assertWritable(p, c, s.memberRoleFor(ctx, p, c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changed := map[string]any{}
|
||||
@@ -160,8 +196,9 @@ func (s *projectService) Archive(ctx context.Context, c Caller, slug string) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 归档/取消归档仍要 owner+admin 校验,但已归档不再额外阻塞 archive。
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID {
|
||||
// 归档/取消归档要 owner / global-admin / project-admin 角色,但已归档不再额外
|
||||
// 阻塞 archive。project-admin 视为项目管理者,可执行生命周期操作。
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID && !s.memberRoleFor(ctx, p, c).CanManageMembers() {
|
||||
if p.Visibility == VisibilityPrivate {
|
||||
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||
}
|
||||
@@ -182,7 +219,7 @@ func (s *projectService) Unarchive(ctx context.Context, c Caller, slug string) e
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID {
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID && !s.memberRoleFor(ctx, p, c).CanManageMembers() {
|
||||
if p.Visibility == VisibilityPrivate {
|
||||
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||
}
|
||||
@@ -198,6 +235,101 @@ func (s *projectService) Unarchive(ctx context.Context, c Caller, slug string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== 成员/角色 ACL(autonomy roadmap §11)=====
|
||||
|
||||
// ListMembers 返回 slug 项目的全部成员。要求 caller 对项目可读(owner/admin/
|
||||
// 任意成员/internal 可见性)。
|
||||
func (s *projectService) ListMembers(ctx context.Context, c Caller, slug string) ([]*Member, error) {
|
||||
p, err := s.repo.GetProjectBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertReadable(p, c, s.memberRoleFor(ctx, p, c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListMembers(ctx, p.ID)
|
||||
}
|
||||
|
||||
// AddMember 新增或更新一名成员的角色。仅 project-admin 角色或 global-admin 可调用。
|
||||
func (s *projectService) AddMember(ctx context.Context, c Caller, slug string, in AddMemberInput) (*Member, error) {
|
||||
if in.UserID == uuid.Nil {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "user_id 必填")
|
||||
}
|
||||
if !in.Role.IsValid() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "role 非法(仅 viewer/member/admin)")
|
||||
}
|
||||
p, err := s.repo.GetProjectBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.assertCanManageMembers(ctx, p, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addedBy := c.UserID
|
||||
m, err := s.repo.UpsertMember(ctx, p.ID, in.UserID, in.Role, &addedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordAudit(ctx, c, "project.member_add", p.ID.String(), map[string]any{
|
||||
"user_id": in.UserID.String(), "role": string(in.Role),
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// RemoveMember 移除一名成员。仅 project-admin 角色或 global-admin 可调用。
|
||||
// 拒绝移除项目 owner(owner 经回填为 admin,移除会让其失去隐式 owner 之外的角色行,
|
||||
// 但 owner 仍由 OwnerID 兜底拥有权限——为避免误导,直接拒绝移除 owner)。
|
||||
func (s *projectService) RemoveMember(ctx context.Context, c Caller, slug string, userID uuid.UUID) error {
|
||||
if userID == uuid.Nil {
|
||||
return errs.New(errs.CodeInvalidInput, "user_id 必填")
|
||||
}
|
||||
p, err := s.repo.GetProjectBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.assertCanManageMembers(ctx, p, c); err != nil {
|
||||
return err
|
||||
}
|
||||
if userID == p.OwnerID {
|
||||
return errs.New(errs.CodeForbidden, "不能移除项目 owner")
|
||||
}
|
||||
n, err := s.repo.DeleteMember(ctx, p.ID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return errs.New(errs.CodeNotFound, "成员不存在")
|
||||
}
|
||||
s.recordAudit(ctx, c, "project.member_remove", p.ID.String(), map[string]any{
|
||||
"user_id": userID.String(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemberRole 返回 userID 在 projectID 的成员角色(非成员返回 RoleNone)。
|
||||
// owner/global-admin 的隐式 admin 不在此体现——本方法只读 project_members 行;
|
||||
// app 层 projectACL 自行叠加 owner/admin 判定。
|
||||
func (s *projectService) MemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) {
|
||||
return s.repo.GetMemberRole(ctx, projectID, userID)
|
||||
}
|
||||
|
||||
// assertCanManageMembers 校验 caller 可管理 p 的成员:global-admin、owner 或
|
||||
// project-admin 角色。private 不可见者返回 NotFound 防探测。
|
||||
func (s *projectService) assertCanManageMembers(ctx context.Context, p *Project, c Caller) error {
|
||||
if c.IsAdmin || p.OwnerID == c.UserID {
|
||||
return nil
|
||||
}
|
||||
role := s.memberRoleFor(ctx, p, c)
|
||||
if role.CanManageMembers() {
|
||||
return nil
|
||||
}
|
||||
// 不可读者:private 返回 NotFound 防探测;否则 Forbidden。
|
||||
if assertReadable(p, c, role) != nil {
|
||||
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||
}
|
||||
return errs.New(errs.CodeForbidden, "无权管理项目成员")
|
||||
}
|
||||
|
||||
// recordAudit 是 best-effort 写入:失败仅吞错(不影响业务)。ctx 用
|
||||
// context.WithoutCancel 派生,使 HTTP 响应已返回但审计仍能落库。
|
||||
func (s *projectService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
|
||||
|
||||
@@ -19,11 +19,14 @@ import (
|
||||
// fakeRepo 是内存版 Repository,仅用于 service 单测;按 ID/slug 查找用 map,
|
||||
// number 自增手动维护。并发安全(一把大锁就够)。
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
projects map[uuid.UUID]*Project
|
||||
requirements map[uuid.UUID]*Requirement
|
||||
issues map[uuid.UUID]*Issue
|
||||
artifacts map[uuid.UUID]*Artifact
|
||||
mu sync.Mutex
|
||||
projects map[uuid.UUID]*Project
|
||||
requirements map[uuid.UUID]*Requirement
|
||||
issues map[uuid.UUID]*Issue
|
||||
artifacts map[uuid.UUID]*Artifact
|
||||
phasePointers map[string]*PhasePointer // key: requirementID + "|" + phase
|
||||
dependencies map[uuid.UUID]*Dependency
|
||||
members map[string]*Member // key: projectID + "|" + userID
|
||||
|
||||
// 置 true 时,下一次 CreateArtifact 返回一次真实的 PG 唯一约束冲突后自动复位,
|
||||
// 用于覆盖 artifactService.createWithRetry 的 version 竞争重试分支。
|
||||
@@ -32,13 +35,24 @@ type fakeRepo struct {
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{
|
||||
projects: map[uuid.UUID]*Project{},
|
||||
requirements: map[uuid.UUID]*Requirement{},
|
||||
issues: map[uuid.UUID]*Issue{},
|
||||
artifacts: map[uuid.UUID]*Artifact{},
|
||||
projects: map[uuid.UUID]*Project{},
|
||||
requirements: map[uuid.UUID]*Requirement{},
|
||||
issues: map[uuid.UUID]*Issue{},
|
||||
artifacts: map[uuid.UUID]*Artifact{},
|
||||
phasePointers: map[string]*PhasePointer{},
|
||||
dependencies: map[uuid.UUID]*Dependency{},
|
||||
members: map[string]*Member{},
|
||||
}
|
||||
}
|
||||
|
||||
func memberKey(projectID, userID uuid.UUID) string {
|
||||
return projectID.String() + "|" + userID.String()
|
||||
}
|
||||
|
||||
func phasePointerKey(reqID uuid.UUID, phase Phase) string {
|
||||
return reqID.String() + "|" + string(phase)
|
||||
}
|
||||
|
||||
func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v }
|
||||
func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v }
|
||||
func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v }
|
||||
@@ -127,6 +141,51 @@ func (r *fakeRepo) UnarchiveProject(_ context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== ProjectMember =====
|
||||
|
||||
func (r *fakeRepo) UpsertMember(_ context.Context, projectID, userID uuid.UUID, role Role, addedBy *uuid.UUID) (*Member, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
m := &Member{ProjectID: projectID, UserID: userID, Role: role, AddedBy: addedBy, CreatedAt: time.Now()}
|
||||
r.members[memberKey(projectID, userID)] = m
|
||||
cp := *m
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) DeleteMember(_ context.Context, projectID, userID uuid.UUID) (int64, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
k := memberKey(projectID, userID)
|
||||
if _, ok := r.members[k]; !ok {
|
||||
return 0, nil
|
||||
}
|
||||
delete(r.members, k)
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetMemberRole(_ context.Context, projectID, userID uuid.UUID) (Role, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
m, ok := r.members[memberKey(projectID, userID)]
|
||||
if !ok {
|
||||
return RoleNone, nil
|
||||
}
|
||||
return m.Role, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListMembers(_ context.Context, projectID uuid.UUID) ([]*Member, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]*Member, 0)
|
||||
for _, m := range r.members {
|
||||
if m.ProjectID == projectID {
|
||||
cp := *m
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ===== Requirement =====
|
||||
|
||||
func (r *fakeRepo) nextRequirementNumber(projectID uuid.UUID) int {
|
||||
@@ -279,6 +338,17 @@ func (r *fakeRepo) GetIssueByNumber(_ context.Context, projectID uuid.UUID, numb
|
||||
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetIssueByID(_ context.Context, id uuid.UUID) (*Issue, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, x := range r.issues {
|
||||
if x.ID == id {
|
||||
return r.cloneIssue(x), nil
|
||||
}
|
||||
}
|
||||
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListIssues(_ context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -369,6 +439,145 @@ func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpdateIssuePriority(_ context.Context, id uuid.UUID, priority int) (*Issue, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cur, ok := r.issues[id]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
|
||||
}
|
||||
cur.Priority = priority
|
||||
cur.UpdatedAt = time.Now()
|
||||
return r.cloneIssue(cur), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CreateDependency(_ context.Context, d *Dependency) (*Dependency, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, x := range r.dependencies {
|
||||
if x.BlockedID == d.BlockedID && x.BlockerID == d.BlockerID {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "依赖已存在")
|
||||
}
|
||||
}
|
||||
d.CreatedAt = time.Now()
|
||||
v := *d
|
||||
r.dependencies[d.ID] = &v
|
||||
out := *d
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) DeleteDependency(_ context.Context, projectID, blockedID, blockerID uuid.UUID) (int64, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for id, x := range r.dependencies {
|
||||
if x.ProjectID == projectID && x.BlockedID == blockedID && x.BlockerID == blockerID {
|
||||
delete(r.dependencies, id)
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListBlockers(_ context.Context, blockedID uuid.UUID) ([]*Issue, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]*Issue, 0)
|
||||
for _, d := range r.dependencies {
|
||||
if d.BlockedID == blockedID {
|
||||
if iss, ok := r.issues[d.BlockerID]; ok {
|
||||
out = append(out, r.cloneIssue(iss))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListBlocked(_ context.Context, blockerID uuid.UUID) ([]*Issue, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]*Issue, 0)
|
||||
for _, d := range r.dependencies {
|
||||
if d.BlockerID == blockerID {
|
||||
if iss, ok := r.issues[d.BlockedID]; ok {
|
||||
out = append(out, r.cloneIssue(iss))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListBlockerIDs(_ context.Context, blockedID uuid.UUID) ([]uuid.UUID, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]uuid.UUID, 0)
|
||||
for _, d := range r.dependencies {
|
||||
if d.BlockedID == blockedID {
|
||||
out = append(out, d.BlockerID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListSubtasks(_ context.Context, parentID uuid.UUID) ([]*Issue, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]*Issue, 0)
|
||||
for _, x := range r.issues {
|
||||
if x.ParentID != nil && *x.ParentID == parentID {
|
||||
out = append(out, r.cloneIssue(x))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListReadyLeafSubtasks(_ context.Context, projectID uuid.UUID, limit int) ([]*Issue, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]*Issue, 0)
|
||||
for _, x := range r.issues {
|
||||
if x.ProjectID != projectID || x.Status != StatusOpen || x.ParentID == nil {
|
||||
continue
|
||||
}
|
||||
blocked := false
|
||||
for _, d := range r.dependencies {
|
||||
if d.BlockedID == x.ID {
|
||||
if b, ok := r.issues[d.BlockerID]; ok && b.Status != StatusClosed {
|
||||
blocked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if blocked {
|
||||
continue
|
||||
}
|
||||
out = append(out, r.cloneIssue(x))
|
||||
}
|
||||
if limit > 0 && len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListSchedulableProjects(_ context.Context) ([]uuid.UUID, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
seen := map[uuid.UUID]struct{}{}
|
||||
out := make([]uuid.UUID, 0)
|
||||
for _, x := range r.issues {
|
||||
if x.Status == StatusOpen && x.ParentID != nil {
|
||||
if _, ok := seen[x.ProjectID]; !ok {
|
||||
seen[x.ProjectID] = struct{}{}
|
||||
out = append(out, x.ProjectID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CountActiveSessionsForProject(_ context.Context, _ uuid.UUID) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetMaxArtifactVersion(_ context.Context, requirementID uuid.UUID, phase Phase) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -395,6 +604,9 @@ func (r *fakeRepo) CreateArtifact(_ context.Context, in *Artifact) (*Artifact, e
|
||||
}
|
||||
}
|
||||
in.CreatedAt = time.Now()
|
||||
if in.Verdict == "" {
|
||||
in.Verdict = VerdictNone // 模拟 DB 列默认值
|
||||
}
|
||||
r.artifacts[in.ID] = r.cloneArtifact(in)
|
||||
return r.cloneArtifact(in), nil
|
||||
}
|
||||
@@ -428,6 +640,68 @@ func (r *fakeRepo) GetArtifactByVersion(_ context.Context, requirementID uuid.UU
|
||||
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetArtifactByID(_ context.Context, id uuid.UUID) (*Artifact, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if x, ok := r.artifacts[id]; ok {
|
||||
return r.cloneArtifact(x), nil
|
||||
}
|
||||
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ApproveArtifact(_ context.Context, artifactID uuid.UUID, verdict Verdict) (*Artifact, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
x, ok := r.artifacts[artifactID]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
|
||||
}
|
||||
x.Verdict = verdict
|
||||
return r.cloneArtifact(x), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpsertPhasePointer(_ context.Context, requirementID uuid.UUID, phase Phase, artifactID, approvedBy uuid.UUID) (*PhasePointer, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
now := time.Now()
|
||||
aid, by := artifactID, approvedBy
|
||||
ptr := &PhasePointer{
|
||||
RequirementID: requirementID,
|
||||
Phase: phase,
|
||||
ApprovedArtifactID: &aid,
|
||||
ApprovedBy: &by,
|
||||
ApprovedAt: &now,
|
||||
}
|
||||
r.phasePointers[phasePointerKey(requirementID, phase)] = ptr
|
||||
v := *ptr
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetPhasePointer(_ context.Context, requirementID uuid.UUID, phase Phase) (*PhasePointer, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
ptr, ok := r.phasePointers[phasePointerKey(requirementID, phase)]
|
||||
if !ok {
|
||||
return nil, errs.New(errs.CodeNotFound, "phase pointer 不存在")
|
||||
}
|
||||
v := *ptr
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListPhasePointers(_ context.Context, requirementID uuid.UUID) ([]*PhasePointer, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]*PhasePointer, 0)
|
||||
for _, ptr := range r.phasePointers {
|
||||
if ptr.RequirementID == requirementID {
|
||||
v := *ptr
|
||||
out = append(out, &v)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Phase < out[j].Phase })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// spyAudit 收集所有写入的 audit entry,方便断言。
|
||||
type spyAudit struct {
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
-- name: CreateRequirementArtifact :one
|
||||
INSERT INTO requirement_artifacts (id, requirement_id, phase, version, content, note, source_message_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at;
|
||||
RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict;
|
||||
|
||||
-- name: ListRequirementArtifactsByRequirement :many
|
||||
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
|
||||
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict
|
||||
FROM requirement_artifacts
|
||||
WHERE requirement_id = $1 AND ($2::text = '' OR phase = $2)
|
||||
ORDER BY phase ASC, version ASC;
|
||||
|
||||
-- name: GetRequirementArtifactByVersion :one
|
||||
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
|
||||
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict
|
||||
FROM requirement_artifacts
|
||||
WHERE requirement_id = $1 AND phase = $2 AND version = $3;
|
||||
|
||||
-- name: GetRequirementArtifactByID :one
|
||||
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict
|
||||
FROM requirement_artifacts
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetMaxRequirementArtifactVersion :one
|
||||
SELECT COALESCE(MAX(version), 0)::int FROM requirement_artifacts
|
||||
WHERE requirement_id = $1 AND phase = $2;
|
||||
|
||||
-- name: ApproveRequirementArtifact :one
|
||||
UPDATE requirement_artifacts SET verdict = $2 WHERE id = $1
|
||||
RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
-- name: CreateDependency :one
|
||||
INSERT INTO issue_dependencies (id, project_id, blocked_id, blocker_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, project_id, blocked_id, blocker_id, created_by, created_at;
|
||||
|
||||
-- name: DeleteDependency :execrows
|
||||
DELETE FROM issue_dependencies
|
||||
WHERE project_id = $1 AND blocked_id = $2 AND blocker_id = $3;
|
||||
|
||||
-- name: ListBlockers :many
|
||||
-- Issues that block $1 (the dependencies of blocked issue $1).
|
||||
SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status,
|
||||
i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority
|
||||
FROM issue_dependencies d
|
||||
JOIN issues i ON i.id = d.blocker_id
|
||||
WHERE d.blocked_id = $1
|
||||
ORDER BY i.number ASC;
|
||||
|
||||
-- name: ListBlocked :many
|
||||
-- Issues blocked by $1 (issues that depend on blocker $1).
|
||||
SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status,
|
||||
i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority
|
||||
FROM issue_dependencies d
|
||||
JOIN issues i ON i.id = d.blocked_id
|
||||
WHERE d.blocker_id = $1
|
||||
ORDER BY i.number ASC;
|
||||
|
||||
-- name: ListSubtasks :many
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
FROM issues
|
||||
WHERE parent_id = $1
|
||||
ORDER BY priority DESC, number ASC;
|
||||
|
||||
-- name: ListBlockerIDs :many
|
||||
-- Direct blocker issue ids of $1 (one hop along blocked-by edges). Used by the
|
||||
-- service-layer iterative reachability cycle check in AddDependency.
|
||||
SELECT blocker_id FROM issue_dependencies WHERE blocked_id = $1;
|
||||
|
||||
-- name: ListReadyLeafSubtasks :many
|
||||
-- Ready leaf subtasks for a project: open subtasks (parent_id NOT NULL) whose
|
||||
-- every blocker is closed, with no active (starting/running) acp_session, and
|
||||
-- no open child issue (leaf). Ordered by priority DESC, number ASC. LIMIT $2.
|
||||
SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status,
|
||||
i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority
|
||||
FROM issues i
|
||||
WHERE i.project_id = $1
|
||||
AND i.status = 'open'
|
||||
AND i.parent_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issue_dependencies d
|
||||
JOIN issues b ON b.id = d.blocker_id
|
||||
WHERE d.blocked_id = i.id AND b.status <> 'closed'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM acp_sessions s
|
||||
WHERE s.issue_id = i.id AND s.status IN ('starting', 'running')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issues c
|
||||
WHERE c.parent_id = i.id AND c.status = 'open'
|
||||
)
|
||||
ORDER BY i.priority DESC, i.number ASC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListSchedulableProjects :many
|
||||
-- Distinct project ids that currently have at least one ready leaf subtask
|
||||
-- (open subtask whose blockers are all closed and no active session). Cheap
|
||||
-- pre-filter so the scheduler only iterates projects with pending work.
|
||||
SELECT DISTINCT i.project_id
|
||||
FROM issues i
|
||||
WHERE i.status = 'open'
|
||||
AND i.parent_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issue_dependencies d
|
||||
JOIN issues b ON b.id = d.blocker_id
|
||||
WHERE d.blocked_id = i.id AND b.status <> 'closed'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM acp_sessions s
|
||||
WHERE s.issue_id = i.id AND s.status IN ('starting', 'running')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issues c
|
||||
WHERE c.parent_id = i.id AND c.status = 'open'
|
||||
);
|
||||
|
||||
-- name: CountActiveSessionsForProject :one
|
||||
-- Active (starting/running) acp_sessions in a project. Used by the scheduler's
|
||||
-- soft per-project concurrency pre-check.
|
||||
SELECT COUNT(*) FROM acp_sessions
|
||||
WHERE project_id = $1 AND status IN ('starting', 'running');
|
||||
@@ -1,22 +1,28 @@
|
||||
-- name: CreateIssue :one
|
||||
INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id)
|
||||
INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id, parent_id, priority)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
(SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE project_id = $2),
|
||||
$4, $5, $6, $7, $8
|
||||
$4, $5, $6, $7, $8, $9, $10
|
||||
)
|
||||
RETURNING id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id;
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority;
|
||||
|
||||
-- name: GetIssueByNumber :one
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
FROM issues
|
||||
WHERE project_id = $1 AND number = $2;
|
||||
|
||||
-- name: GetIssueByID :one
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
FROM issues
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListIssues :many
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
FROM issues
|
||||
WHERE project_id = $1
|
||||
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
||||
@@ -30,7 +36,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, workspace_id
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
FROM issues
|
||||
WHERE requirement_id = $1
|
||||
ORDER BY number DESC;
|
||||
@@ -44,14 +50,21 @@ SET title = $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, workspace_id;
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority;
|
||||
|
||||
-- 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, workspace_id;
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority;
|
||||
|
||||
-- name: UpdateIssuePriority :one
|
||||
UPDATE issues
|
||||
SET priority = $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, workspace_id, parent_id, priority;
|
||||
|
||||
-- name: CloseIssue :exec
|
||||
UPDATE issues SET status = 'closed', closed_at = now(), updated_at = now()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- name: UpsertProjectMember :one
|
||||
INSERT INTO project_members (project_id, user_id, role, added_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (project_id, user_id)
|
||||
DO UPDATE SET role = EXCLUDED.role
|
||||
RETURNING project_id, user_id, role, created_at, added_by;
|
||||
|
||||
-- name: DeleteProjectMember :execrows
|
||||
DELETE FROM project_members
|
||||
WHERE project_id = $1 AND user_id = $2;
|
||||
|
||||
-- name: GetProjectMember :one
|
||||
SELECT project_id, user_id, role, created_at, added_by
|
||||
FROM project_members
|
||||
WHERE project_id = $1 AND user_id = $2;
|
||||
|
||||
-- name: ListProjectMembers :many
|
||||
SELECT project_id, user_id, role, created_at, added_by
|
||||
FROM project_members
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at ASC;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- name: UpsertRequirementPhasePointer :one
|
||||
INSERT INTO requirement_phase_pointers (requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now(), now())
|
||||
ON CONFLICT (requirement_id, phase) DO UPDATE
|
||||
SET approved_artifact_id = EXCLUDED.approved_artifact_id,
|
||||
approved_by = EXCLUDED.approved_by,
|
||||
approved_at = EXCLUDED.approved_at,
|
||||
updated_at = now()
|
||||
RETURNING requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at;
|
||||
|
||||
-- name: GetRequirementPhasePointer :one
|
||||
SELECT requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at
|
||||
FROM requirement_phase_pointers
|
||||
WHERE requirement_id = $1 AND phase = $2;
|
||||
|
||||
-- name: ListRequirementPhasePointers :many
|
||||
SELECT requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at
|
||||
FROM requirement_phase_pointers
|
||||
WHERE requirement_id = $1
|
||||
ORDER BY phase ASC;
|
||||
@@ -31,6 +31,16 @@ type Repository interface {
|
||||
ArchiveProject(ctx context.Context, id uuid.UUID) error
|
||||
UnarchiveProject(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
// ProjectMember 团队/角色 ACL(autonomy roadmap §11)
|
||||
// UpsertMember 新增或更新一名成员的角色(PK 冲突时更新 role)。
|
||||
UpsertMember(ctx context.Context, projectID, userID uuid.UUID, role Role, addedBy *uuid.UUID) (*Member, error)
|
||||
// DeleteMember 移除一名成员,返回受影响行数(0 表示不存在)。
|
||||
DeleteMember(ctx context.Context, projectID, userID uuid.UUID) (int64, error)
|
||||
// GetMemberRole 返回 userID 在 projectID 的成员角色;非成员返回 (RoleNone, nil)。
|
||||
GetMemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error)
|
||||
// ListMembers 返回 projectID 的全部成员(按加入时间升序)。
|
||||
ListMembers(ctx context.Context, projectID uuid.UUID) ([]*Member, error)
|
||||
|
||||
// Requirement
|
||||
CreateRequirement(ctx context.Context, r *Requirement) (*Requirement, error)
|
||||
GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Requirement, error)
|
||||
@@ -44,19 +54,48 @@ type Repository interface {
|
||||
// Issue
|
||||
CreateIssue(ctx context.Context, i *Issue) (*Issue, error)
|
||||
GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Issue, error)
|
||||
GetIssueByID(ctx context.Context, id uuid.UUID) (*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, workspaceID *uuid.UUID) (*Issue, error)
|
||||
UpdateIssueAssignee(ctx context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error)
|
||||
UpdateIssuePriority(ctx context.Context, id uuid.UUID, priority int) (*Issue, error)
|
||||
CloseIssue(ctx context.Context, id uuid.UUID) error
|
||||
ReopenIssue(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
// Issue 依赖图(autonomy roadmap §10)
|
||||
CreateDependency(ctx context.Context, d *Dependency) (*Dependency, error)
|
||||
DeleteDependency(ctx context.Context, projectID, blockedID, blockerID uuid.UUID) (int64, error)
|
||||
// ListBlockers 返回阻塞 blockedID 的 issue 列表(其依赖)。
|
||||
ListBlockers(ctx context.Context, blockedID uuid.UUID) ([]*Issue, error)
|
||||
// ListBlocked 返回被 blockerID 阻塞的 issue 列表。
|
||||
ListBlocked(ctx context.Context, blockerID uuid.UUID) ([]*Issue, error)
|
||||
// ListBlockerIDs 返回 blockedID 的直接 blocker id(一跳),供 service 层环检查。
|
||||
ListBlockerIDs(ctx context.Context, blockedID uuid.UUID) ([]uuid.UUID, error)
|
||||
// ListSubtasks 返回 parentID 的全部子 issue。
|
||||
ListSubtasks(ctx context.Context, parentID uuid.UUID) ([]*Issue, error)
|
||||
// ListReadyLeafSubtasks 返回 project 下"就绪可派"的叶子子任务:open、有 parent、
|
||||
// 全部 blocker 已关闭、无活跃 acp_session、无 open 子。按 priority DESC, number ASC 排序。
|
||||
ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*Issue, error)
|
||||
// ListSchedulableProjects 返回当前至少有一个就绪叶子子任务的 project id(调度器预筛)。
|
||||
ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error)
|
||||
// CountActiveSessionsForProject 返回 project 内活跃(starting/running)acp_session 数。
|
||||
CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error)
|
||||
|
||||
// Artifact
|
||||
CreateArtifact(ctx context.Context, a *Artifact) (*Artifact, error)
|
||||
// ListArtifactsByRequirement 的 phase 传零值("")表示不过滤阶段。
|
||||
ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error)
|
||||
GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error)
|
||||
GetArtifactByID(ctx context.Context, id uuid.UUID) (*Artifact, error)
|
||||
GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error)
|
||||
// ApproveArtifact 给指定产物落 verdict(pass/fail/none),返回更新后的产物。
|
||||
ApproveArtifact(ctx context.Context, artifactID uuid.UUID, verdict Verdict) (*Artifact, error)
|
||||
|
||||
// PhasePointer:每 (requirement, phase) 的已批准/当前产物指针。
|
||||
UpsertPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase, artifactID uuid.UUID, approvedBy uuid.UUID) (*PhasePointer, error)
|
||||
GetPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase) (*PhasePointer, error)
|
||||
ListPhasePointers(ctx context.Context, requirementID uuid.UUID) ([]*PhasePointer, error)
|
||||
}
|
||||
|
||||
// IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create*
|
||||
@@ -211,6 +250,72 @@ func (r *pgRepo) UnarchiveProject(ctx context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== ProjectMember =====
|
||||
|
||||
func rowToMember(row projectsqlc.ProjectMember) *Member {
|
||||
return &Member{
|
||||
ProjectID: fromPgUUID(row.ProjectID),
|
||||
UserID: fromPgUUID(row.UserID),
|
||||
Role: Role(row.Role),
|
||||
AddedBy: fromPgUUIDPtr(row.AddedBy),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *pgRepo) UpsertMember(ctx context.Context, projectID, userID uuid.UUID, role Role, addedBy *uuid.UUID) (*Member, error) {
|
||||
row, err := r.q.UpsertProjectMember(ctx, projectsqlc.UpsertProjectMemberParams{
|
||||
ProjectID: toPgUUID(projectID),
|
||||
UserID: toPgUUID(userID),
|
||||
Role: string(role),
|
||||
AddedBy: toPgUUIDPtr(addedBy),
|
||||
})
|
||||
if err != nil {
|
||||
// FK 冲突:user 不存在 → invalid_input(不泄漏 5xx)。
|
||||
if IsForeignKeyViolation(err) {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "用户不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "upsert project member")
|
||||
}
|
||||
return rowToMember(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) DeleteMember(ctx context.Context, projectID, userID uuid.UUID) (int64, error) {
|
||||
n, err := r.q.DeleteProjectMember(ctx, projectsqlc.DeleteProjectMemberParams{
|
||||
ProjectID: toPgUUID(projectID),
|
||||
UserID: toPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, errs.Wrap(err, errs.CodeInternal, "delete project member")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetMemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) {
|
||||
row, err := r.q.GetProjectMember(ctx, projectsqlc.GetProjectMemberParams{
|
||||
ProjectID: toPgUUID(projectID),
|
||||
UserID: toPgUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return RoleNone, nil
|
||||
}
|
||||
return RoleNone, errs.Wrap(err, errs.CodeInternal, "get project member role")
|
||||
}
|
||||
return Role(row.Role), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListMembers(ctx context.Context, projectID uuid.UUID) ([]*Member, error) {
|
||||
rows, err := r.q.ListProjectMembers(ctx, toPgUUID(projectID))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list project members")
|
||||
}
|
||||
out := make([]*Member, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToMember(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ===== Requirement =====
|
||||
|
||||
func rowToRequirement(row projectsqlc.Requirement) *Requirement {
|
||||
@@ -366,6 +471,8 @@ func rowToIssue(row projectsqlc.Issue) *Issue {
|
||||
ClosedAt: fromPgTimePtr(row.ClosedAt),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
ParentID: fromPgUUIDPtr(row.ParentID),
|
||||
Priority: int(row.Priority),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +486,8 @@ func (r *pgRepo) CreateIssue(ctx context.Context, in *Issue) (*Issue, error) {
|
||||
AssigneeID: toPgUUIDPtr(in.AssigneeID),
|
||||
CreatedBy: toPgUUID(in.CreatedBy),
|
||||
WorkspaceID: toPgUUIDPtr(in.WorkspaceID),
|
||||
ParentID: toPgUUIDPtr(in.ParentID),
|
||||
Priority: int32(in.Priority), //nolint:gosec // priority 0..3,CHECK 约束兜底
|
||||
})
|
||||
if err != nil {
|
||||
if IsUniqueViolation(err) {
|
||||
@@ -406,6 +515,17 @@ func (r *pgRepo) GetIssueByNumber(ctx context.Context, projectID uuid.UUID, numb
|
||||
return rowToIssue(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetIssueByID(ctx context.Context, id uuid.UUID) (*Issue, error) {
|
||||
row, err := r.q.GetIssueByID(ctx, toPgUUID(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "get issue by id")
|
||||
}
|
||||
return rowToIssue(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListIssues(ctx context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) {
|
||||
var statusPtr, reqFilter *string
|
||||
if filter.Status != "" {
|
||||
@@ -499,6 +619,151 @@ func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) UpdateIssuePriority(ctx context.Context, id uuid.UUID, priority int) (*Issue, error) {
|
||||
row, err := r.q.UpdateIssuePriority(ctx, projectsqlc.UpdateIssuePriorityParams{
|
||||
ID: toPgUUID(id),
|
||||
Priority: int32(priority), //nolint:gosec // priority 0..3,CHECK 约束兜底
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errs.New(errs.CodeNotFound, "issue 不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "update issue priority")
|
||||
}
|
||||
return rowToIssue(row), nil
|
||||
}
|
||||
|
||||
// ===== Issue 依赖图(autonomy roadmap §10)=====
|
||||
|
||||
func rowToDependency(row projectsqlc.IssueDependency) *Dependency {
|
||||
return &Dependency{
|
||||
ID: fromPgUUID(row.ID),
|
||||
ProjectID: fromPgUUID(row.ProjectID),
|
||||
BlockedID: fromPgUUID(row.BlockedID),
|
||||
BlockerID: fromPgUUID(row.BlockerID),
|
||||
CreatedBy: fromPgUUID(row.CreatedBy),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *pgRepo) CreateDependency(ctx context.Context, d *Dependency) (*Dependency, error) {
|
||||
row, err := r.q.CreateDependency(ctx, projectsqlc.CreateDependencyParams{
|
||||
ID: toPgUUID(d.ID),
|
||||
ProjectID: toPgUUID(d.ProjectID),
|
||||
BlockedID: toPgUUID(d.BlockedID),
|
||||
BlockerID: toPgUUID(d.BlockerID),
|
||||
CreatedBy: toPgUUID(d.CreatedBy),
|
||||
})
|
||||
if err != nil {
|
||||
if IsUniqueViolation(err) {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "依赖已存在")
|
||||
}
|
||||
if IsForeignKeyViolation(err) {
|
||||
return nil, errs.Wrap(err, errs.CodeInvalidInput, "依赖两端 issue 不存在或跨 project")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "create dependency")
|
||||
}
|
||||
return rowToDependency(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) DeleteDependency(ctx context.Context, projectID, blockedID, blockerID uuid.UUID) (int64, error) {
|
||||
n, err := r.q.DeleteDependency(ctx, projectsqlc.DeleteDependencyParams{
|
||||
ProjectID: toPgUUID(projectID),
|
||||
BlockedID: toPgUUID(blockedID),
|
||||
BlockerID: toPgUUID(blockerID),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, errs.Wrap(err, errs.CodeInternal, "delete dependency")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListBlockers(ctx context.Context, blockedID uuid.UUID) ([]*Issue, error) {
|
||||
rows, err := r.q.ListBlockers(ctx, toPgUUID(blockedID))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list blockers")
|
||||
}
|
||||
out := make([]*Issue, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToIssue(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListBlocked(ctx context.Context, blockerID uuid.UUID) ([]*Issue, error) {
|
||||
rows, err := r.q.ListBlocked(ctx, toPgUUID(blockerID))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list blocked")
|
||||
}
|
||||
out := make([]*Issue, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToIssue(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListBlockerIDs(ctx context.Context, blockedID uuid.UUID) ([]uuid.UUID, error) {
|
||||
rows, err := r.q.ListBlockerIDs(ctx, toPgUUID(blockedID))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list blocker ids")
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
out = append(out, fromPgUUID(p))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListSubtasks(ctx context.Context, parentID uuid.UUID) ([]*Issue, error) {
|
||||
rows, err := r.q.ListSubtasks(ctx, toPgUUID(parentID))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list subtasks")
|
||||
}
|
||||
out := make([]*Issue, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToIssue(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*Issue, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := r.q.ListReadyLeafSubtasks(ctx, projectsqlc.ListReadyLeafSubtasksParams{
|
||||
ProjectID: toPgUUID(projectID),
|
||||
Limit: int32(limit), //nolint:gosec // limit 来自配置/调用方,正向小整数
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list ready leaf subtasks")
|
||||
}
|
||||
out := make([]*Issue, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToIssue(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) {
|
||||
rows, err := r.q.ListSchedulableProjects(ctx)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list schedulable projects")
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
out = append(out, fromPgUUID(p))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) {
|
||||
n, err := r.q.CountActiveSessionsForProject(ctx, toPgUUID(projectID))
|
||||
if err != nil {
|
||||
return 0, errs.Wrap(err, errs.CodeInternal, "count active sessions for project")
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// ===== Artifact =====
|
||||
|
||||
func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact {
|
||||
@@ -512,6 +777,17 @@ func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact {
|
||||
SourceMessageID: row.SourceMessageID,
|
||||
CreatedBy: fromPgUUID(row.CreatedBy),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
Verdict: Verdict(row.Verdict),
|
||||
}
|
||||
}
|
||||
|
||||
func rowToPhasePointer(row projectsqlc.RequirementPhasePointer) *PhasePointer {
|
||||
return &PhasePointer{
|
||||
RequirementID: fromPgUUID(row.RequirementID),
|
||||
Phase: Phase(row.Phase),
|
||||
ApprovedArtifactID: fromPgUUIDPtr(row.ApprovedArtifactID),
|
||||
ApprovedBy: fromPgUUIDPtr(row.ApprovedBy),
|
||||
ApprovedAt: fromPgTimePtr(row.ApprovedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,3 +851,69 @@ func (r *pgRepo) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.U
|
||||
}
|
||||
return int(max), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetArtifactByID(ctx context.Context, id uuid.UUID) (*Artifact, error) {
|
||||
row, err := r.q.GetRequirementArtifactByID(ctx, toPgUUID(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "get artifact by id")
|
||||
}
|
||||
return rowToArtifact(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ApproveArtifact(ctx context.Context, artifactID uuid.UUID, verdict Verdict) (*Artifact, error) {
|
||||
row, err := r.q.ApproveRequirementArtifact(ctx, projectsqlc.ApproveRequirementArtifactParams{
|
||||
ID: toPgUUID(artifactID),
|
||||
Verdict: string(verdict),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errs.New(errs.CodeNotFound, "artifact 不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "approve artifact")
|
||||
}
|
||||
return rowToArtifact(row), nil
|
||||
}
|
||||
|
||||
// ===== PhasePointer =====
|
||||
|
||||
func (r *pgRepo) UpsertPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase, artifactID, approvedBy uuid.UUID) (*PhasePointer, error) {
|
||||
row, err := r.q.UpsertRequirementPhasePointer(ctx, projectsqlc.UpsertRequirementPhasePointerParams{
|
||||
RequirementID: toPgUUID(requirementID),
|
||||
Phase: string(phase),
|
||||
ApprovedArtifactID: toPgUUID(artifactID),
|
||||
ApprovedBy: toPgUUID(approvedBy),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "upsert phase pointer")
|
||||
}
|
||||
return rowToPhasePointer(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase) (*PhasePointer, error) {
|
||||
row, err := r.q.GetRequirementPhasePointer(ctx, projectsqlc.GetRequirementPhasePointerParams{
|
||||
RequirementID: toPgUUID(requirementID),
|
||||
Phase: string(phase),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errs.New(errs.CodeNotFound, "phase pointer 不存在")
|
||||
}
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "get phase pointer")
|
||||
}
|
||||
return rowToPhasePointer(row), nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) ListPhasePointers(ctx context.Context, requirementID uuid.UUID) ([]*PhasePointer, error) {
|
||||
rows, err := r.q.ListRequirementPhasePointers(ctx, toPgUUID(requirementID))
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err, errs.CodeInternal, "list phase pointers")
|
||||
}
|
||||
out := make([]*PhasePointer, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowToPhasePointer(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -19,22 +19,28 @@ import (
|
||||
)
|
||||
|
||||
type requirementService struct {
|
||||
repo Repository
|
||||
audit audit.Recorder
|
||||
repo Repository
|
||||
audit audit.Recorder
|
||||
emitter PhaseEventEmitter
|
||||
gate GateRunner
|
||||
}
|
||||
|
||||
// NewRequirementService 构造 RequirementService。
|
||||
func NewRequirementService(repo Repository, rec audit.Recorder) RequirementService {
|
||||
return &requirementService{repo: repo, audit: rec}
|
||||
//
|
||||
// emitter 为 nil 时不发 requirement.phase_change 领域事件(仅审计),gate 为 nil
|
||||
// 时保持 any-to-any 旧行为(不做 entry 网关校验)——二者均允许测试/精简场景注入 nil。
|
||||
func NewRequirementService(repo Repository, rec audit.Recorder, emitter PhaseEventEmitter, gate GateRunner) RequirementService {
|
||||
return &requirementService{repo: repo, audit: rec, emitter: emitter, gate: gate}
|
||||
}
|
||||
|
||||
// loadProjectForWrite 解析 slug → 校验 caller 写权限 → 校验未归档。
|
||||
// 成员/角色 ACL:member/admin 角色叠加写授权(见 assertWritable)。
|
||||
func loadProjectForWrite(ctx context.Context, repo Repository, c Caller, slug string) (*Project, error) {
|
||||
p, err := repo.GetProjectBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertWritable(p, c); err != nil {
|
||||
if err := assertWritable(p, c, callerRole(ctx, repo, p, c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
@@ -45,12 +51,25 @@ func loadProjectForRead(ctx context.Context, repo Repository, c Caller, slug str
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertReadable(p, c); err != nil {
|
||||
if err := assertReadable(p, c, callerRole(ctx, repo, p, c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// callerRole 是 loadProjectFor* 的成员角色查询 helper(不在 projectService 上,
|
||||
// 故走 repo)。owner/global-admin 隐式 admin;查询失败按 RoleNone(既有规则兜底)。
|
||||
func callerRole(ctx context.Context, repo Repository, p *Project, c Caller) Role {
|
||||
if c.IsAdmin || p.OwnerID == c.UserID {
|
||||
return RoleAdmin
|
||||
}
|
||||
role, err := repo.GetMemberRole(ctx, p.ID, c.UserID)
|
||||
if err != nil {
|
||||
return RoleNone
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
func (s *requirementService) Create(ctx context.Context, c Caller, slug string, in CreateRequirementInput) (*Requirement, error) {
|
||||
if in.Title == "" {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "title 必填")
|
||||
@@ -193,6 +212,13 @@ func (s *requirementService) ChangePhase(ctx context.Context, c Caller, slug str
|
||||
return r, nil
|
||||
}
|
||||
from := r.Phase
|
||||
// entry 网关:目标 phase 声明的全部前置谓词必须通过。失败返回 CodePhaseGateFailed,
|
||||
// 且不写库、不发事件。gate 为 nil 时跳过(保持 any-to-any)。
|
||||
if s.gate != nil {
|
||||
if err := s.gate.Check(ctx, r, from, to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out, err := s.repo.UpdateRequirementPhase(ctx, r.ID, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -200,6 +226,19 @@ func (s *requirementService) ChangePhase(ctx context.Context, c Caller, slug str
|
||||
s.recordAudit(ctx, c, "requirement.phase_change", out.ID.String(), map[string]any{
|
||||
"from": string(from), "to": string(to),
|
||||
})
|
||||
// 领域事件(与审计互补):fire-and-forget,投递由 emitter 异步处理,失败不阻塞流转。
|
||||
if s.emitter != nil {
|
||||
s.emitter.EmitPhaseChange(context.WithoutCancel(ctx), PhaseChangeEvent{
|
||||
ProjectID: p.ID,
|
||||
ProjectSlug: p.Slug,
|
||||
RequirementID: out.ID,
|
||||
RequirementNumber: out.Number,
|
||||
From: from,
|
||||
To: to,
|
||||
ActorID: c.UserID,
|
||||
OwnerID: out.OwnerID,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
func newReqSvc(repo *fakeRepo) (RequirementService, ProjectService, *spyAudit) {
|
||||
spy := &spyAudit{}
|
||||
return NewRequirementService(repo, spy), NewProjectService(repo, spy), spy
|
||||
return NewRequirementService(repo, spy, nil, nil), NewProjectService(repo, spy), spy
|
||||
}
|
||||
|
||||
func TestRequirementService_Create_HappyAndAutoIncrement(t *testing.T) {
|
||||
@@ -175,3 +175,130 @@ func TestRequirementService_Update_WorkspaceID_TriState(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, out.WorkspaceID)
|
||||
}
|
||||
|
||||
// fakeEmitter 收集 EmitPhaseChange 调用,便于断言事件发出且仅发一次。
|
||||
type fakeEmitter struct {
|
||||
events []PhaseChangeEvent
|
||||
}
|
||||
|
||||
func (e *fakeEmitter) EmitPhaseChange(_ context.Context, ev PhaseChangeEvent) {
|
||||
e.events = append(e.events, ev)
|
||||
}
|
||||
|
||||
func newReqSvcWith(repo *fakeRepo, emitter PhaseEventEmitter, gate GateRunner) (RequirementService, *spyAudit) {
|
||||
spy := &spyAudit{}
|
||||
return NewRequirementService(repo, spy, emitter, gate), spy
|
||||
}
|
||||
|
||||
func TestRequirementService_ChangePhase_EmitsEvent(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
emitter := &fakeEmitter{}
|
||||
svc, spy := newReqSvcWith(repo, emitter, nil)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseAuditing)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PhaseAuditing, out.Phase)
|
||||
|
||||
require.Len(t, emitter.events, 1)
|
||||
ev := emitter.events[0]
|
||||
require.Equal(t, PhasePlanning, ev.From)
|
||||
require.Equal(t, PhaseAuditing, ev.To)
|
||||
require.Equal(t, "demo", ev.ProjectSlug)
|
||||
require.Equal(t, r.Number, ev.RequirementNumber)
|
||||
require.Equal(t, owner, ev.ActorID)
|
||||
// 事件与审计互补:审计仍记一条。
|
||||
require.Contains(t, spy.actions(), "requirement.phase_change")
|
||||
}
|
||||
|
||||
func TestRequirementService_ChangePhase_NoopEmitsNothing(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
emitter := &fakeEmitter{}
|
||||
svc, _ := newReqSvcWith(repo, emitter, nil)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 当前已是 planning,切到 planning 是 no-op。
|
||||
_, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhasePlanning)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, emitter.events)
|
||||
}
|
||||
|
||||
func TestRequirementService_ChangePhase_GateBlocksAndSkipsEmit(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
emitter := &fakeEmitter{}
|
||||
gate := NewGateRunner(GateDeps{Repo: repo}, nil)
|
||||
svc, spy := newReqSvcWith(repo, emitter, gate)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// implementing 需要已批准的 auditing 产物,缺失 → phase_gate_failed。
|
||||
_, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseImplementing)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodePhaseGateFailed, ae.Code)
|
||||
|
||||
// gate 失败不写库、不发事件、不记 phase_change 审计。
|
||||
require.Empty(t, emitter.events)
|
||||
require.NotContains(t, spy.actions(), "requirement.phase_change")
|
||||
got, err := svc.Get(context.Background(), caller, "demo", r.Number)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PhasePlanning, got.Phase)
|
||||
}
|
||||
|
||||
func TestRequirementService_ChangePhase_ClosedCheckedBeforeGate(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
emitter := &fakeEmitter{}
|
||||
gate := NewGateRunner(GateDeps{Repo: repo}, nil)
|
||||
svc, _ := newReqSvcWith(repo, emitter, gate)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, svc.Close(context.Background(), caller, "demo", r.Number))
|
||||
|
||||
// 关闭检查先于 gate:即便目标是被网关的 implementing,也返回 requirement_closed。
|
||||
_, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseImplementing)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeRequirementClosed, ae.Code)
|
||||
require.Empty(t, emitter.events)
|
||||
}
|
||||
|
||||
func TestRequirementService_ChangePhase_GateAllowsAfterApproval(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
emitter := &fakeEmitter{}
|
||||
gate := NewGateRunner(GateDeps{Repo: repo}, nil)
|
||||
svc, _ := newReqSvcWith(repo, emitter, gate)
|
||||
artSvc := NewArtifactService(repo, nil)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
||||
require.NoError(t, err)
|
||||
_, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"})
|
||||
require.NoError(t, err)
|
||||
_, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseImplementing)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PhaseImplementing, out.Phase)
|
||||
require.Len(t, emitter.events, 1)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,38 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const approveRequirementArtifact = `-- name: ApproveRequirementArtifact :one
|
||||
UPDATE requirement_artifacts SET verdict = $2 WHERE id = $1
|
||||
RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict
|
||||
`
|
||||
|
||||
type ApproveRequirementArtifactParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
func (q *Queries) ApproveRequirementArtifact(ctx context.Context, arg ApproveRequirementArtifactParams) (RequirementArtifact, error) {
|
||||
row := q.db.QueryRow(ctx, approveRequirementArtifact, arg.ID, arg.Verdict)
|
||||
var i RequirementArtifact
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.RequirementID,
|
||||
&i.Phase,
|
||||
&i.Version,
|
||||
&i.Content,
|
||||
&i.Note,
|
||||
&i.SourceMessageID,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.Verdict,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createRequirementArtifact = `-- name: CreateRequirementArtifact :one
|
||||
INSERT INTO requirement_artifacts (id, requirement_id, phase, version, content, note, source_message_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
|
||||
RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict
|
||||
`
|
||||
|
||||
type CreateRequirementArtifactParams struct {
|
||||
@@ -50,6 +78,7 @@ func (q *Queries) CreateRequirementArtifact(ctx context.Context, arg CreateRequi
|
||||
&i.SourceMessageID,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.Verdict,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -71,8 +100,32 @@ func (q *Queries) GetMaxRequirementArtifactVersion(ctx context.Context, arg GetM
|
||||
return column_1, err
|
||||
}
|
||||
|
||||
const getRequirementArtifactByID = `-- name: GetRequirementArtifactByID :one
|
||||
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict
|
||||
FROM requirement_artifacts
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetRequirementArtifactByID(ctx context.Context, id pgtype.UUID) (RequirementArtifact, error) {
|
||||
row := q.db.QueryRow(ctx, getRequirementArtifactByID, id)
|
||||
var i RequirementArtifact
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.RequirementID,
|
||||
&i.Phase,
|
||||
&i.Version,
|
||||
&i.Content,
|
||||
&i.Note,
|
||||
&i.SourceMessageID,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.Verdict,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRequirementArtifactByVersion = `-- name: GetRequirementArtifactByVersion :one
|
||||
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
|
||||
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict
|
||||
FROM requirement_artifacts
|
||||
WHERE requirement_id = $1 AND phase = $2 AND version = $3
|
||||
`
|
||||
@@ -96,12 +149,13 @@ func (q *Queries) GetRequirementArtifactByVersion(ctx context.Context, arg GetRe
|
||||
&i.SourceMessageID,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.Verdict,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listRequirementArtifactsByRequirement = `-- name: ListRequirementArtifactsByRequirement :many
|
||||
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at
|
||||
SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict
|
||||
FROM requirement_artifacts
|
||||
WHERE requirement_id = $1 AND ($2::text = '' OR phase = $2)
|
||||
ORDER BY phase ASC, version ASC
|
||||
@@ -131,6 +185,7 @@ func (q *Queries) ListRequirementArtifactsByRequirement(ctx context.Context, arg
|
||||
&i.SourceMessageID,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.Verdict,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: dependencies.sql
|
||||
|
||||
package projectsqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countActiveSessionsForProject = `-- name: CountActiveSessionsForProject :one
|
||||
SELECT COUNT(*) FROM acp_sessions
|
||||
WHERE project_id = $1 AND status IN ('starting', 'running')
|
||||
`
|
||||
|
||||
// Active (starting/running) acp_sessions in a project. Used by the scheduler's
|
||||
// soft per-project concurrency pre-check.
|
||||
func (q *Queries) CountActiveSessionsForProject(ctx context.Context, projectID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countActiveSessionsForProject, projectID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createDependency = `-- name: CreateDependency :one
|
||||
INSERT INTO issue_dependencies (id, project_id, blocked_id, blocker_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, project_id, blocked_id, blocker_id, created_by, created_at
|
||||
`
|
||||
|
||||
type CreateDependencyParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
BlockedID pgtype.UUID `json:"blocked_id"`
|
||||
BlockerID pgtype.UUID `json:"blocker_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateDependency(ctx context.Context, arg CreateDependencyParams) (IssueDependency, error) {
|
||||
row := q.db.QueryRow(ctx, createDependency,
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.BlockedID,
|
||||
arg.BlockerID,
|
||||
arg.CreatedBy,
|
||||
)
|
||||
var i IssueDependency
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.BlockedID,
|
||||
&i.BlockerID,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteDependency = `-- name: DeleteDependency :execrows
|
||||
DELETE FROM issue_dependencies
|
||||
WHERE project_id = $1 AND blocked_id = $2 AND blocker_id = $3
|
||||
`
|
||||
|
||||
type DeleteDependencyParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
BlockedID pgtype.UUID `json:"blocked_id"`
|
||||
BlockerID pgtype.UUID `json:"blocker_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteDependency(ctx context.Context, arg DeleteDependencyParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteDependency, arg.ProjectID, arg.BlockedID, arg.BlockerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const listBlocked = `-- name: ListBlocked :many
|
||||
SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status,
|
||||
i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority
|
||||
FROM issue_dependencies d
|
||||
JOIN issues i ON i.id = d.blocked_id
|
||||
WHERE d.blocker_id = $1
|
||||
ORDER BY i.number ASC
|
||||
`
|
||||
|
||||
// Issues blocked by $1 (issues that depend on blocker $1).
|
||||
func (q *Queries) ListBlocked(ctx context.Context, blockerID pgtype.UUID) ([]Issue, error) {
|
||||
rows, err := q.db.Query(ctx, listBlocked, blockerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Issue
|
||||
for rows.Next() {
|
||||
var i Issue
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.Number,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.AssigneeID,
|
||||
&i.CreatedBy,
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listBlockerIDs = `-- name: ListBlockerIDs :many
|
||||
SELECT blocker_id FROM issue_dependencies WHERE blocked_id = $1
|
||||
`
|
||||
|
||||
// Direct blocker issue ids of $1 (one hop along blocked-by edges). Used by the
|
||||
// service-layer iterative reachability cycle check in AddDependency.
|
||||
func (q *Queries) ListBlockerIDs(ctx context.Context, blockedID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, listBlockerIDs, blockedID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var blocker_id pgtype.UUID
|
||||
if err := rows.Scan(&blocker_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, blocker_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listBlockers = `-- name: ListBlockers :many
|
||||
SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status,
|
||||
i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority
|
||||
FROM issue_dependencies d
|
||||
JOIN issues i ON i.id = d.blocker_id
|
||||
WHERE d.blocked_id = $1
|
||||
ORDER BY i.number ASC
|
||||
`
|
||||
|
||||
// Issues that block $1 (the dependencies of blocked issue $1).
|
||||
func (q *Queries) ListBlockers(ctx context.Context, blockedID pgtype.UUID) ([]Issue, error) {
|
||||
rows, err := q.db.Query(ctx, listBlockers, blockedID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Issue
|
||||
for rows.Next() {
|
||||
var i Issue
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.Number,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.AssigneeID,
|
||||
&i.CreatedBy,
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listReadyLeafSubtasks = `-- name: ListReadyLeafSubtasks :many
|
||||
SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status,
|
||||
i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority
|
||||
FROM issues i
|
||||
WHERE i.project_id = $1
|
||||
AND i.status = 'open'
|
||||
AND i.parent_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issue_dependencies d
|
||||
JOIN issues b ON b.id = d.blocker_id
|
||||
WHERE d.blocked_id = i.id AND b.status <> 'closed'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM acp_sessions s
|
||||
WHERE s.issue_id = i.id AND s.status IN ('starting', 'running')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issues c
|
||||
WHERE c.parent_id = i.id AND c.status = 'open'
|
||||
)
|
||||
ORDER BY i.priority DESC, i.number ASC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListReadyLeafSubtasksParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
// Ready leaf subtasks for a project: open subtasks (parent_id NOT NULL) whose
|
||||
// every blocker is closed, with no active (starting/running) acp_session, and
|
||||
// no open child issue (leaf). Ordered by priority DESC, number ASC. LIMIT $2.
|
||||
func (q *Queries) ListReadyLeafSubtasks(ctx context.Context, arg ListReadyLeafSubtasksParams) ([]Issue, error) {
|
||||
rows, err := q.db.Query(ctx, listReadyLeafSubtasks, arg.ProjectID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Issue
|
||||
for rows.Next() {
|
||||
var i Issue
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.Number,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.AssigneeID,
|
||||
&i.CreatedBy,
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSchedulableProjects = `-- name: ListSchedulableProjects :many
|
||||
SELECT DISTINCT i.project_id
|
||||
FROM issues i
|
||||
WHERE i.status = 'open'
|
||||
AND i.parent_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issue_dependencies d
|
||||
JOIN issues b ON b.id = d.blocker_id
|
||||
WHERE d.blocked_id = i.id AND b.status <> 'closed'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM acp_sessions s
|
||||
WHERE s.issue_id = i.id AND s.status IN ('starting', 'running')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issues c
|
||||
WHERE c.parent_id = i.id AND c.status = 'open'
|
||||
)
|
||||
`
|
||||
|
||||
// Distinct project ids that currently have at least one ready leaf subtask
|
||||
// (open subtask whose blockers are all closed and no active session). Cheap
|
||||
// pre-filter so the scheduler only iterates projects with pending work.
|
||||
func (q *Queries) ListSchedulableProjects(ctx context.Context) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, listSchedulableProjects)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var project_id pgtype.UUID
|
||||
if err := rows.Scan(&project_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, project_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSubtasks = `-- name: ListSubtasks :many
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
FROM issues
|
||||
WHERE parent_id = $1
|
||||
ORDER BY priority DESC, number ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListSubtasks(ctx context.Context, parentID pgtype.UUID) ([]Issue, error) {
|
||||
rows, err := q.db.Query(ctx, listSubtasks, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Issue
|
||||
for rows.Next() {
|
||||
var i Issue
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.Number,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.AssigneeID,
|
||||
&i.CreatedBy,
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -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, workspace_id)
|
||||
INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id, parent_id, priority)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
(SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE project_id = $2),
|
||||
$4, $5, $6, $7, $8
|
||||
$4, $5, $6, $7, $8, $9, $10
|
||||
)
|
||||
RETURNING id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
`
|
||||
|
||||
type CreateIssueParams struct {
|
||||
@@ -41,6 +41,8 @@ type CreateIssueParams struct {
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ParentID pgtype.UUID `json:"parent_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
|
||||
@@ -53,6 +55,8 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
|
||||
arg.AssigneeID,
|
||||
arg.CreatedBy,
|
||||
arg.WorkspaceID,
|
||||
arg.ParentID,
|
||||
arg.Priority,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
@@ -69,13 +73,45 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getIssueByID = `-- name: GetIssueByID :one
|
||||
SELECT id, project_id, requirement_id, number, title, description, status,
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
FROM issues
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetIssueByID(ctx context.Context, id pgtype.UUID) (Issue, error) {
|
||||
row := q.db.QueryRow(ctx, getIssueByID, id)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.Number,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.AssigneeID,
|
||||
&i.CreatedBy,
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
)
|
||||
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, workspace_id
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
FROM issues
|
||||
WHERE project_id = $1 AND number = $2
|
||||
`
|
||||
@@ -102,13 +138,15 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
)
|
||||
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, workspace_id
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
FROM issues
|
||||
WHERE project_id = $1
|
||||
AND ($2::text IS NULL OR status = $2)
|
||||
@@ -158,6 +196,8 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -171,7 +211,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, workspace_id
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
FROM issues
|
||||
WHERE requirement_id = $1
|
||||
ORDER BY number DESC
|
||||
@@ -200,6 +240,8 @@ func (q *Queries) ListIssuesByRequirement(ctx context.Context, requirementID pgt
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -226,7 +268,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, workspace_id
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
`
|
||||
|
||||
type UpdateIssueAssigneeParams struct {
|
||||
@@ -251,6 +293,8 @@ func (q *Queries) UpdateIssueAssignee(ctx context.Context, arg UpdateIssueAssign
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -264,7 +308,7 @@ SET title = $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, workspace_id
|
||||
assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority
|
||||
`
|
||||
|
||||
type UpdateIssueCoreParams struct {
|
||||
@@ -298,6 +342,44 @@ func (q *Queries) UpdateIssueCore(ctx context.Context, arg UpdateIssueCoreParams
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateIssuePriority = `-- name: UpdateIssuePriority :one
|
||||
UPDATE issues
|
||||
SET priority = $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, workspace_id, parent_id, priority
|
||||
`
|
||||
|
||||
type UpdateIssuePriorityParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Priority int32 `json:"priority"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateIssuePriority(ctx context.Context, arg UpdateIssuePriorityParams) (Issue, error) {
|
||||
row := q.db.QueryRow(ctx, updateIssuePriority, arg.ID, arg.Priority)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.RequirementID,
|
||||
&i.Number,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.AssigneeID,
|
||||
&i.CreatedBy,
|
||||
&i.ClosedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WorkspaceID,
|
||||
&i.ParentID,
|
||||
&i.Priority,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: members.sql
|
||||
|
||||
package projectsqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteProjectMember = `-- name: DeleteProjectMember :execrows
|
||||
DELETE FROM project_members
|
||||
WHERE project_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteProjectMemberParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteProjectMember(ctx context.Context, arg DeleteProjectMemberParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteProjectMember, arg.ProjectID, arg.UserID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const getProjectMember = `-- name: GetProjectMember :one
|
||||
SELECT project_id, user_id, role, created_at, added_by
|
||||
FROM project_members
|
||||
WHERE project_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetProjectMemberParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectMember(ctx context.Context, arg GetProjectMemberParams) (ProjectMember, error) {
|
||||
row := q.db.QueryRow(ctx, getProjectMember, arg.ProjectID, arg.UserID)
|
||||
var i ProjectMember
|
||||
err := row.Scan(
|
||||
&i.ProjectID,
|
||||
&i.UserID,
|
||||
&i.Role,
|
||||
&i.CreatedAt,
|
||||
&i.AddedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listProjectMembers = `-- name: ListProjectMembers :many
|
||||
SELECT project_id, user_id, role, created_at, added_by
|
||||
FROM project_members
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListProjectMembers(ctx context.Context, projectID pgtype.UUID) ([]ProjectMember, error) {
|
||||
rows, err := q.db.Query(ctx, listProjectMembers, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ProjectMember
|
||||
for rows.Next() {
|
||||
var i ProjectMember
|
||||
if err := rows.Scan(
|
||||
&i.ProjectID,
|
||||
&i.UserID,
|
||||
&i.Role,
|
||||
&i.CreatedAt,
|
||||
&i.AddedBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertProjectMember = `-- name: UpsertProjectMember :one
|
||||
INSERT INTO project_members (project_id, user_id, role, added_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (project_id, user_id)
|
||||
DO UPDATE SET role = EXCLUDED.role
|
||||
RETURNING project_id, user_id, role, created_at, added_by
|
||||
`
|
||||
|
||||
type UpsertProjectMemberParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
AddedBy pgtype.UUID `json:"added_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertProjectMember(ctx context.Context, arg UpsertProjectMemberParams) (ProjectMember, error) {
|
||||
row := q.db.QueryRow(ctx, upsertProjectMember,
|
||||
arg.ProjectID,
|
||||
arg.UserID,
|
||||
arg.Role,
|
||||
arg.AddedBy,
|
||||
)
|
||||
var i ProjectMember
|
||||
err := row.Scan(
|
||||
&i.ProjectID,
|
||||
&i.UserID,
|
||||
&i.Role,
|
||||
&i.CreatedAt,
|
||||
&i.AddedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
+240
-17
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/pgvector/pgvector-go"
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
@@ -25,6 +26,11 @@ type AcpAgentKind struct {
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
|
||||
MaxTokens *int64 `json:"max_tokens"`
|
||||
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
@@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct {
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
@@ -64,23 +71,64 @@ type AcpPermissionRequest struct {
|
||||
}
|
||||
|
||||
type AcpSession struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
AgentSessionID *string `json:"agent_session_id"`
|
||||
Branch string `json:"branch"`
|
||||
CwdPath string `json:"cwd_path"`
|
||||
IsMainWorktree bool `json:"is_main_worktree"`
|
||||
Status string `json:"status"`
|
||||
Pid *int32 `json:"pid"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
LastError *string `json:"last_error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
AgentSessionID *string `json:"agent_session_id"`
|
||||
Branch string `json:"branch"`
|
||||
CwdPath string `json:"cwd_path"`
|
||||
IsMainWorktree bool `json:"is_main_worktree"`
|
||||
Status string `json:"status"`
|
||||
Pid *int32 `json:"pid"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
LastError *string `json:"last_error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
LastStopReason *string `json:"last_stop_reason"`
|
||||
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
ThinkingTokens int64 `json:"thinking_tokens"`
|
||||
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
|
||||
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
|
||||
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
|
||||
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
|
||||
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
|
||||
TerminatedReason *string `json:"terminated_reason"`
|
||||
SandboxMode *string `json:"sandbox_mode"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
TokensTotal int64 `json:"tokens_total"`
|
||||
}
|
||||
|
||||
type AcpSessionUsage struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
ThinkingTokens int64 `json:"thinking_tokens"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
SourceEventID *int64 `json:"source_event_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type AcpTurn struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
TurnIndex int32 `json:"turn_index"`
|
||||
PromptRequestID *string `json:"prompt_request_id"`
|
||||
Status string `json:"status"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
UpdateCount int32 `json:"update_count"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
@@ -94,6 +142,81 @@ type AuditLog struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type ChangeRequest struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Number int32 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
SourceBranch string `json:"source_branch"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
State string `json:"state"`
|
||||
ReviewVerdict string `json:"review_verdict"`
|
||||
CiState string `json:"ci_state"`
|
||||
Provider string `json:"provider"`
|
||||
ExternalID *int64 `json:"external_id"`
|
||||
ExternalUrl string `json:"external_url"`
|
||||
MergeCommitSha string `json:"merge_commit_sha"`
|
||||
ReviewedBy pgtype.UUID `json:"reviewed_by"`
|
||||
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
MergedAt pgtype.Timestamptz `json:"merged_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CodeChunk struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
FilePath string `json:"file_path"`
|
||||
Lang *string `json:"lang"`
|
||||
StartLine int32 `json:"start_line"`
|
||||
EndLine int32 `json:"end_line"`
|
||||
Content string `json:"content"`
|
||||
ContentSha []byte `json:"content_sha"`
|
||||
TokenCount *int32 `json:"token_count"`
|
||||
Embedding *pgvector.Vector `json:"embedding"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CodeIndexRun struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
WorktreeID pgtype.UUID `json:"worktree_id"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
Status string `json:"status"`
|
||||
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
|
||||
EmbeddingModel string `json:"embedding_model"`
|
||||
Dims int32 `json:"dims"`
|
||||
FilesIndexed int32 `json:"files_indexed"`
|
||||
ChunksIndexed int32 `json:"chunks_indexed"`
|
||||
Error *string `json:"error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CommitSummary struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
WorktreeID pgtype.UUID `json:"worktree_id"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
Kind string `json:"kind"`
|
||||
Title *string `json:"title"`
|
||||
BodyMd string `json:"body_md"`
|
||||
Model *string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Error *string `json:"error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
@@ -110,6 +233,14 @@ type Conversation struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
}
|
||||
|
||||
type CryptoKey struct {
|
||||
Version int32 `json:"version"`
|
||||
Provider string `json:"provider"`
|
||||
KeyRef *string `json:"key_ref"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type GitCredential struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
@@ -119,6 +250,7 @@ type GitCredential struct {
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
@@ -135,6 +267,17 @@ type Issue struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ParentID pgtype.UUID `json:"parent_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
}
|
||||
|
||||
type IssueDependency struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
BlockedID pgtype.UUID `json:"blocked_id"`
|
||||
BlockerID pgtype.UUID `json:"blocker_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
@@ -162,6 +305,7 @@ type LlmEndpoint struct {
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type LlmModel struct {
|
||||
@@ -252,6 +396,50 @@ type Notification struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type NotificationPref struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
ImEnabled bool `json:"im_enabled"`
|
||||
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
|
||||
MinSeverity string `json:"min_severity"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OrchestratorRun struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
CurrentPhase string `json:"current_phase"`
|
||||
Config []byte `json:"config"`
|
||||
LastError *string `json:"last_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type OrchestratorStep struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
Phase string `json:"phase"`
|
||||
Attempt int32 `json:"attempt"`
|
||||
ParentStepID pgtype.UUID `json:"parent_step_id"`
|
||||
Depth int32 `json:"depth"`
|
||||
Status string `json:"status"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
AcpSessionID pgtype.UUID `json:"acp_session_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
GateResult []byte `json:"gate_result"`
|
||||
LastError *string `json:"last_error"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -264,6 +452,30 @@ type Project struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ProjectMember struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
AddedBy pgtype.UUID `json:"added_by"`
|
||||
}
|
||||
|
||||
type ProjectMemory struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Tags []string `json:"tags"`
|
||||
Source string `json:"source"`
|
||||
Embedding *pgvector.Vector `json:"embedding"`
|
||||
EmbeddingModel *string `json:"embedding_model"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PromptTemplate struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -299,6 +511,16 @@ type RequirementArtifact struct {
|
||||
SourceMessageID *int64 `json:"source_message_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
type RequirementPhasePointer struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
Phase string `json:"phase"`
|
||||
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
|
||||
ApprovedBy pgtype.UUID `json:"approved_by"`
|
||||
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -354,6 +576,7 @@ type WorkspaceRunProfile struct {
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type WorkspaceWorktree struct {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: phase_pointers.sql
|
||||
|
||||
package projectsqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getRequirementPhasePointer = `-- name: GetRequirementPhasePointer :one
|
||||
SELECT requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at
|
||||
FROM requirement_phase_pointers
|
||||
WHERE requirement_id = $1 AND phase = $2
|
||||
`
|
||||
|
||||
type GetRequirementPhasePointerParams struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRequirementPhasePointer(ctx context.Context, arg GetRequirementPhasePointerParams) (RequirementPhasePointer, error) {
|
||||
row := q.db.QueryRow(ctx, getRequirementPhasePointer, arg.RequirementID, arg.Phase)
|
||||
var i RequirementPhasePointer
|
||||
err := row.Scan(
|
||||
&i.RequirementID,
|
||||
&i.Phase,
|
||||
&i.ApprovedArtifactID,
|
||||
&i.ApprovedBy,
|
||||
&i.ApprovedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listRequirementPhasePointers = `-- name: ListRequirementPhasePointers :many
|
||||
SELECT requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at
|
||||
FROM requirement_phase_pointers
|
||||
WHERE requirement_id = $1
|
||||
ORDER BY phase ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListRequirementPhasePointers(ctx context.Context, requirementID pgtype.UUID) ([]RequirementPhasePointer, error) {
|
||||
rows, err := q.db.Query(ctx, listRequirementPhasePointers, requirementID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []RequirementPhasePointer
|
||||
for rows.Next() {
|
||||
var i RequirementPhasePointer
|
||||
if err := rows.Scan(
|
||||
&i.RequirementID,
|
||||
&i.Phase,
|
||||
&i.ApprovedArtifactID,
|
||||
&i.ApprovedBy,
|
||||
&i.ApprovedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertRequirementPhasePointer = `-- name: UpsertRequirementPhasePointer :one
|
||||
INSERT INTO requirement_phase_pointers (requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now(), now())
|
||||
ON CONFLICT (requirement_id, phase) DO UPDATE
|
||||
SET approved_artifact_id = EXCLUDED.approved_artifact_id,
|
||||
approved_by = EXCLUDED.approved_by,
|
||||
approved_at = EXCLUDED.approved_at,
|
||||
updated_at = now()
|
||||
RETURNING requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertRequirementPhasePointerParams struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
Phase string `json:"phase"`
|
||||
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
|
||||
ApprovedBy pgtype.UUID `json:"approved_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertRequirementPhasePointer(ctx context.Context, arg UpsertRequirementPhasePointerParams) (RequirementPhasePointer, error) {
|
||||
row := q.db.QueryRow(ctx, upsertRequirementPhasePointer,
|
||||
arg.RequirementID,
|
||||
arg.Phase,
|
||||
arg.ApprovedArtifactID,
|
||||
arg.ApprovedBy,
|
||||
)
|
||||
var i RequirementPhasePointer
|
||||
err := row.Scan(
|
||||
&i.RequirementID,
|
||||
&i.Phase,
|
||||
&i.ApprovedArtifactID,
|
||||
&i.ApprovedBy,
|
||||
&i.ApprovedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
Reference in New Issue
Block a user