You've already forked agentic-coding-workflow
feat(project): RequirementService with phase state machine + close/reopen
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
// Package project 内的 requirement_service.go 实装 RequirementService。
|
||||
//
|
||||
// 设计取舍:
|
||||
// - phase 不强制单向,但必须在 AllPhases 枚举内。
|
||||
// - 已 closed requirement:禁止 phase 切换 + 禁止挂新 issue(后者由
|
||||
// IssueService 实施);现有挂载 issue 仍可正常修改。
|
||||
// - 已归档 project 下任何写操作直接 CodeProjectArchived。
|
||||
// - number 自增由 SQL 子查询 + UNIQUE 兜底;service 在 Create 上捕获
|
||||
// unique_violation 重试 1 次,再次失败转 CodeInternal。
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
type requirementService struct {
|
||||
repo Repository
|
||||
audit audit.Recorder
|
||||
}
|
||||
|
||||
// NewRequirementService 构造 RequirementService。
|
||||
func NewRequirementService(repo Repository, rec audit.Recorder) RequirementService {
|
||||
return &requirementService{repo: repo, audit: rec}
|
||||
}
|
||||
|
||||
// loadProjectForWrite 解析 slug → 校验 caller 写权限 → 校验未归档。
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func loadProjectForRead(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 := assertReadable(p, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
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 必填")
|
||||
}
|
||||
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner := c.UserID
|
||||
if in.OwnerID != nil {
|
||||
owner = *in.OwnerID
|
||||
}
|
||||
in1 := &Requirement{
|
||||
ID: uuid.New(), ProjectID: p.ID,
|
||||
Title: in.Title, Description: in.Description,
|
||||
OwnerID: owner,
|
||||
}
|
||||
out, err := s.createWithRetry(ctx, in1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordAudit(ctx, c, "requirement.create", out.ID.String(), map[string]any{
|
||||
"project_id": p.ID.String(), "number": out.Number, "title": out.Title,
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// createWithRetry 把 number 竞争吞下并重试 1 次;再次失败转 CodeInternal。
|
||||
func (s *requirementService) createWithRetry(ctx context.Context, in *Requirement) (*Requirement, error) {
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
out, err := s.repo.CreateRequirement(ctx, in)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if !IsUniqueViolation(err) {
|
||||
return nil, err
|
||||
}
|
||||
// number 撞了,下一轮 SQL 子查询会拿到新 max
|
||||
}
|
||||
return nil, errs.New(errs.CodeInternal, "requirement number 持续冲突")
|
||||
}
|
||||
|
||||
func (s *requirementService) Get(ctx context.Context, c Caller, slug string, number int) (*Requirement, error) {
|
||||
p, err := loadProjectForRead(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetRequirementByNumber(ctx, p.ID, number)
|
||||
}
|
||||
|
||||
func (s *requirementService) List(ctx context.Context, c Caller, slug string, f RequirementFilter) ([]*Requirement, error) {
|
||||
p, err := loadProjectForRead(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.Phase != "" && !f.Phase.IsValid() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "phase 非法")
|
||||
}
|
||||
if f.Status != "" && f.Status != StatusOpen && f.Status != StatusClosed {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "status 非法")
|
||||
}
|
||||
return s.repo.ListRequirements(ctx, p.ID, f.Phase, f.Status)
|
||||
}
|
||||
|
||||
func (s *requirementService) Update(ctx context.Context, c Caller, slug string, number int, in UpdateRequirementInput) (*Requirement, error) {
|
||||
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changed := map[string]any{}
|
||||
if in.Title != nil {
|
||||
r.Title = *in.Title
|
||||
changed["title"] = "<changed>"
|
||||
}
|
||||
if in.Description != nil {
|
||||
r.Description = *in.Description
|
||||
changed["description"] = "<changed>"
|
||||
}
|
||||
if in.OwnerID != nil {
|
||||
r.OwnerID = *in.OwnerID
|
||||
changed["owner_id"] = in.OwnerID.String()
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
return r, nil
|
||||
}
|
||||
out, err := s.repo.UpdateRequirement(ctx, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordAudit(ctx, c, "requirement.update", out.ID.String(), map[string]any{"changed_fields": changed})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *requirementService) ChangePhase(ctx context.Context, c Caller, slug string, number int, to Phase) (*Requirement, error) {
|
||||
if !to.IsValid() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "phase 非法")
|
||||
}
|
||||
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Status == StatusClosed {
|
||||
return nil, errs.New(errs.CodeRequirementClosed, "已关闭需求禁止 phase 切换")
|
||||
}
|
||||
if r.Phase == to {
|
||||
return r, nil
|
||||
}
|
||||
from := r.Phase
|
||||
out, err := s.repo.UpdateRequirementPhase(ctx, r.ID, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordAudit(ctx, c, "requirement.phase_change", out.ID.String(), map[string]any{
|
||||
"from": string(from), "to": string(to),
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *requirementService) Close(ctx context.Context, c Caller, slug string, number int) error {
|
||||
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, number)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Status == StatusClosed {
|
||||
return nil
|
||||
}
|
||||
if err := s.repo.CloseRequirement(ctx, r.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.recordAudit(ctx, c, "requirement.close", r.ID.String(), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *requirementService) Reopen(ctx context.Context, c Caller, slug string, number int) error {
|
||||
p, err := loadProjectForWrite(ctx, s.repo, c, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := s.repo.GetRequirementByNumber(ctx, p.ID, number)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Status == StatusOpen {
|
||||
return nil
|
||||
}
|
||||
if err := s.repo.ReopenRequirement(ctx, r.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.recordAudit(ctx, c, "requirement.reopen", r.ID.String(), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *requirementService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
uid := c.UserID
|
||||
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
|
||||
UserID: &uid, Action: action, TargetType: "requirement", TargetID: targetID, Metadata: meta,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
func newReqSvc(repo *fakeRepo) (RequirementService, ProjectService, *spyAudit) {
|
||||
spy := &spyAudit{}
|
||||
return NewRequirementService(repo, spy), NewProjectService(repo, spy), spy
|
||||
}
|
||||
|
||||
func TestRequirementService_Create_HappyAndAutoIncrement(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, spy := newReqSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
|
||||
r1, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "First"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, r1.Number)
|
||||
|
||||
r2, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "Second"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, r2.Number)
|
||||
|
||||
require.Contains(t, spy.actions(), "requirement.create")
|
||||
}
|
||||
|
||||
func TestRequirementService_PhaseChange_FollowsEnum(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newReqSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 任意跳跃合法:planning → reviewing
|
||||
out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseReviewing)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PhaseReviewing, out.Phase)
|
||||
|
||||
// 非枚举字符串 → invalid
|
||||
_, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, "bogus")
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
||||
}
|
||||
|
||||
func TestRequirementService_PhaseChange_BlockedWhenClosed(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newReqSvc(repo)
|
||||
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))
|
||||
|
||||
_, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseAuditing)
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeRequirementClosed, ae.Code)
|
||||
}
|
||||
|
||||
func TestRequirementService_Write_BlockedWhenProjectArchived(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
reqSvc, projSvc, _ := newReqSvc(repo)
|
||||
caller := Caller{UserID: owner}
|
||||
require.NoError(t, projSvc.Archive(context.Background(), caller, "demo"))
|
||||
|
||||
_, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "X"})
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errs.CodeProjectArchived, ae.Code)
|
||||
}
|
||||
|
||||
func TestRequirementService_Reopen_AllowsPhaseAgain(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
owner := uuid.New()
|
||||
seedProject(t, repo, owner)
|
||||
svc, _, _ := newReqSvc(repo)
|
||||
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))
|
||||
require.NoError(t, svc.Reopen(context.Background(), caller, "demo", r.Number))
|
||||
|
||||
out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseDone)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PhaseDone, out.Phase)
|
||||
}
|
||||
Reference in New Issue
Block a user