// 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 } 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 { return nil, err } meta := map[string]any{"project_id": p.ID.String(), "number": out.Number} if reqID != nil { meta["requirement_id"] = reqID.String() } 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) 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 changed := map[string]any{} if in.Title != nil && *in.Title != cur.Title { title = *in.Title changed["title"] = "" } if in.Description != nil && *in.Description != cur.Description { desc = *in.Description changed["description"] = "" } // requirement 三态:未传(in.RequirementNumber == nil)→ 保持 // 传 nil 指针(**nil)→ detach // 传值(*RequirementNumber 不是 nil)→ resolve & validate reqID := cur.RequirementID if in.RequirementNumber != nil { auditFrom := "" if cur.RequirementID != nil { auditFrom = cur.RequirementID.String() } var auditTo 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() } if auditFrom != auditTo { changed["from_requirement"] = auditFrom changed["to_requirement"] = auditTo } } // workspace 三态:nil(未提供)/ &uuid.Nil(解绑)/ &id(关联) nextWS := cur.WorkspaceID if in.WorkspaceID != nil { if *in.WorkspaceID == uuid.Nil { nextWS = nil } else { v := *in.WorkspaceID nextWS = &v } auditFrom := "" if cur.WorkspaceID != nil { auditFrom = cur.WorkspaceID.String() } auditTo := "" if nextWS != nil { auditTo = nextWS.String() } if auditFrom != auditTo { changed["from_workspace"] = auditFrom changed["to_workspace"] = auditTo } } if len(changed) == 0 { return cur, nil } out, err := s.repo.UpdateIssueCore(ctx, cur.ID, title, desc, reqID, nextWS) if err != nil { return nil, err } s.recordAudit(ctx, c, "issue.update", out.ID.String(), map[string]any{"changed_fields": changed}) 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 } // ===== 任务分解 / 依赖图(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 } uid := c.UserID _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ UserID: &uid, Action: action, TargetType: "issue", TargetID: targetID, Metadata: meta, }) }