// 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, }) }