You've already forked agentic-coding-workflow
feat(project): IssueService with attach/detach + assign + close/reopen
This commit is contained in:
@@ -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 = "<unchanged>"
|
||||
if cur.RequirementID != nil {
|
||||
auditFrom = cur.RequirementID.String()
|
||||
}
|
||||
if *in.RequirementNumber == nil {
|
||||
reqID = nil
|
||||
auditTo = "<none>"
|
||||
} 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": "<various>"}
|
||||
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 := "<none>"
|
||||
if cur.AssigneeID != nil {
|
||||
from = cur.AssigneeID.String()
|
||||
}
|
||||
to := "<none>"
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user