From ccf4a1c8ee63e005460543f4bd6b2baf73208d98 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 1 May 2026 10:18:10 +0800 Subject: [PATCH] feat(project): IssueService with attach/detach + assign + close/reopen --- internal/project/issue_service.go | 246 +++++++++++++++++++++++++ internal/project/issue_service_test.go | 116 ++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 internal/project/issue_service.go create mode 100644 internal/project/issue_service_test.go diff --git a/internal/project/issue_service.go b/internal/project/issue_service.go new file mode 100644 index 0000000..fcf9050 --- /dev/null +++ b/internal/project/issue_service.go @@ -0,0 +1,246 @@ +// Package project 内的 issue_service.go 实装 IssueService。 +// +// 校验要点: +// - requirement 必须与 issue 同 project(fakeRepo / sql 都按 project_id 索引, +// 此处的校验主要在 service 层把 RequirementNumber 解析为 RequirementID)。 +// - 已 closed requirement:拒绝挂新 issue;已挂载 issue 仍可改其它字段, +// 但不能从游离/别处移过来。 +// - 已归档 project:所有写操作拒绝。 +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 issueService struct { + repo Repository + audit audit.Recorder +} + +// NewIssueService 构造 IssueService。 +func NewIssueService(repo Repository, rec audit.Recorder) IssueService { + return &issueService{repo: repo, audit: rec} +} + +// resolveRequirement 把 *number 解析为 *Requirement,并校验同 project + 未关闭。 +// number == nil → 返回 (nil, nil) 表示不挂载。 +func (s *issueService) resolveRequirement(ctx context.Context, p *Project, number *int) (*Requirement, error) { + if number == nil { + return nil, nil + } + 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, "目标需求已关闭,无法挂载") + } + return r, nil +} + +func (s *issueService) Create(ctx context.Context, c Caller, slug string, in CreateIssueInput) (*Issue, 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 + } + r, err := s.resolveRequirement(ctx, p, in.RequirementNumber) + if err != nil { + return nil, err + } + var reqID *uuid.UUID + if r != nil { + reqID = &r.ID + } + in1 := &Issue{ + ID: uuid.New(), ProjectID: p.ID, RequirementID: reqID, + Title: in.Title, Description: in.Description, + AssigneeID: in.AssigneeID, CreatedBy: c.UserID, + } + out, err := s.createWithRetry(ctx, in1) + if err != nil { + return nil, err + } + meta := map[string]any{"project_id": p.ID.String(), "number": out.Number} + if reqID != nil { + meta["requirement_id"] = reqID.String() + } + s.recordAudit(ctx, c, "issue.create", out.ID.String(), meta) + return out, 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) + if err == nil { + return out, nil + } + if !IsUniqueViolation(err) { + return nil, err + } + } + return nil, errs.New(errs.CodeInternal, "issue number 持续冲突") +} + +func (s *issueService) Get(ctx context.Context, c Caller, slug string, number int) (*Issue, error) { + p, err := loadProjectForRead(ctx, s.repo, c, slug) + if err != nil { + return nil, err + } + return s.repo.GetIssueByNumber(ctx, p.ID, number) +} + +func (s *issueService) List(ctx context.Context, c Caller, slug string, f IssueFilter) ([]*Issue, error) { + p, err := loadProjectForRead(ctx, s.repo, c, slug) + if err != nil { + return nil, err + } + if f.Status != "" && f.Status != StatusOpen && f.Status != StatusClosed { + return nil, errs.New(errs.CodeInvalidInput, "status 非法") + } + // f.Requirement 三态:nil / *==0 (none) / *>0 (number → id) + var reqID *uuid.UUID + if f.Requirement != nil && *f.Requirement > 0 { + r, err := s.repo.GetRequirementByNumber(ctx, p.ID, *f.Requirement) + if err != nil { + return nil, err + } + reqID = &r.ID + } + return s.repo.ListIssues(ctx, p.ID, f, reqID) +} + +func (s *issueService) Update(ctx context.Context, c Caller, slug string, number int, in UpdateIssueInput) (*Issue, error) { + p, err := loadProjectForWrite(ctx, s.repo, c, slug) + if err != nil { + return nil, err + } + cur, err := s.repo.GetIssueByNumber(ctx, p.ID, number) + if err != nil { + return nil, err + } + + title, desc := cur.Title, cur.Description + if in.Title != nil { + title = *in.Title + } + if in.Description != nil { + desc = *in.Description + } + + // requirement 三态:未传(in.RequirementNumber == nil)→ 保持 + // 传 nil 指针(**nil)→ detach + // 传值(*RequirementNumber 不是 nil)→ resolve & validate + reqID := cur.RequirementID + auditFrom := "" + auditTo := "" + if in.RequirementNumber != nil { + auditFrom = "" + if cur.RequirementID != nil { + auditFrom = cur.RequirementID.String() + } + if *in.RequirementNumber == nil { + reqID = nil + auditTo = "" + } else { + r, err := s.resolveRequirement(ctx, p, *in.RequirementNumber) + if err != nil { + return nil, err + } + reqID = &r.ID + auditTo = r.ID.String() + } + } + + out, err := s.repo.UpdateIssueCore(ctx, cur.ID, title, desc, reqID) + if err != nil { + return nil, err + } + meta := map[string]any{"changed_fields": ""} + if auditFrom != "" { + meta["from_requirement"] = auditFrom + meta["to_requirement"] = auditTo + } + s.recordAudit(ctx, c, "issue.update", out.ID.String(), meta) + return out, nil +} + +func (s *issueService) Assign(ctx context.Context, c Caller, slug string, number int, assignee *uuid.UUID) (*Issue, error) { + p, err := loadProjectForWrite(ctx, s.repo, c, slug) + if err != nil { + return nil, err + } + cur, err := s.repo.GetIssueByNumber(ctx, p.ID, number) + if err != nil { + return nil, err + } + from := "" + if cur.AssigneeID != nil { + from = cur.AssigneeID.String() + } + to := "" + if assignee != nil { + to = assignee.String() + } + out, err := s.repo.UpdateIssueAssignee(ctx, cur.ID, assignee) + if err != nil { + return nil, err + } + s.recordAudit(ctx, c, "issue.assign", out.ID.String(), map[string]any{"from": from, "to": to}) + return out, nil +} + +func (s *issueService) Close(ctx context.Context, c Caller, slug string, number int) error { + p, err := loadProjectForWrite(ctx, s.repo, c, slug) + if err != nil { + return err + } + cur, err := s.repo.GetIssueByNumber(ctx, p.ID, number) + if err != nil { + return err + } + if cur.Status == StatusClosed { + return nil + } + if err := s.repo.CloseIssue(ctx, cur.ID); err != nil { + return err + } + s.recordAudit(ctx, c, "issue.close", cur.ID.String(), nil) + return nil +} + +func (s *issueService) Reopen(ctx context.Context, c Caller, slug string, number int) error { + p, err := loadProjectForWrite(ctx, s.repo, c, slug) + if err != nil { + return err + } + cur, err := s.repo.GetIssueByNumber(ctx, p.ID, number) + if err != nil { + return err + } + if cur.Status == StatusOpen { + return nil + } + if err := s.repo.ReopenIssue(ctx, cur.ID); err != nil { + return err + } + s.recordAudit(ctx, c, "issue.reopen", cur.ID.String(), nil) + return nil +} + +func (s *issueService) 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: "issue", TargetID: targetID, Metadata: meta, + }) +} diff --git a/internal/project/issue_service_test.go b/internal/project/issue_service_test.go new file mode 100644 index 0000000..b187e32 --- /dev/null +++ b/internal/project/issue_service_test.go @@ -0,0 +1,116 @@ +package project + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +func newIssueSvc(repo *fakeRepo) (IssueService, RequirementService, *spyAudit) { + spy := &spyAudit{} + return NewIssueService(repo, spy), NewRequirementService(repo, spy), spy +} + +func TestIssueService_Create_FreelanceAndAttached(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + issueSvc, reqSvc, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + // 游离 + free, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "Free"}) + require.NoError(t, err) + require.Nil(t, free.RequirementID) + + // 挂载 + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + num := r.Number + att, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "A", RequirementNumber: &num}) + require.NoError(t, err) + require.NotNil(t, att.RequirementID) + require.Equal(t, r.ID, *att.RequirementID) +} + +func TestIssueService_Create_RejectsClosedRequirement(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + issueSvc, reqSvc, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number)) + num := r.Number + _, err = issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "X", RequirementNumber: &num}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeRequirementClosed, ae.Code) +} + +func TestIssueService_Update_MoveBetweenRequirementsAndDetach(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + issueSvc, reqSvc, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + r1, _ := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R1"}) + r2, _ := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R2"}) + n1 := r1.Number + issue, err := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "I", RequirementNumber: &n1}) + require.NoError(t, err) + + // move r1 → r2 + n2 := r2.Number + pn2 := &n2 + out, err := issueSvc.Update(context.Background(), caller, "demo", issue.Number, UpdateIssueInput{RequirementNumber: &pn2}) + require.NoError(t, err) + require.Equal(t, r2.ID, *out.RequirementID) + + // detach (传 nil 指针 → 取消挂载) + var nilPtr *int + out, err = issueSvc.Update(context.Background(), caller, "demo", issue.Number, UpdateIssueInput{RequirementNumber: &nilPtr}) + require.NoError(t, err) + require.Nil(t, out.RequirementID) +} + +func TestIssueService_Assign_AndUnassign(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + issueSvc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + i, _ := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "I"}) + + dev := uuid.New() + out, err := issueSvc.Assign(context.Background(), caller, "demo", i.Number, &dev) + require.NoError(t, err) + require.NotNil(t, out.AssigneeID) + require.Equal(t, dev, *out.AssigneeID) + + out, err = issueSvc.Assign(context.Background(), caller, "demo", i.Number, nil) + require.NoError(t, err) + require.Nil(t, out.AssigneeID) +} + +func TestIssueService_Update_BlockedWhenProjectArchived(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + issueSvc, _, _ := newIssueSvc(repo) + projSvc := NewProjectService(repo, nil) + caller := Caller{UserID: owner} + i, _ := issueSvc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "I"}) + + require.NoError(t, projSvc.Archive(context.Background(), caller, "demo")) + t1 := "Renamed" + _, err := issueSvc.Update(context.Background(), caller, "demo", i.Number, UpdateIssueInput{Title: &t1}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeProjectArchived, ae.Code) +}