Files
2026-06-22 08:55:57 +08:00

447 lines
13 KiB
Go

// 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"] = "<changed>"
}
if in.Description != nil && *in.Description != cur.Description {
desc = *in.Description
changed["description"] = "<changed>"
}
// requirement 三态:未传(in.RequirementNumber == nil)→ 保持
// 传 nil 指针(**nil)→ detach
// 传值(*RequirementNumber 不是 nil)→ resolve & validate
reqID := cur.RequirementID
if in.RequirementNumber != nil {
auditFrom := "<none>"
if cur.RequirementID != nil {
auditFrom = cur.RequirementID.String()
}
var auditTo 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()
}
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 := "<none>"
if cur.WorkspaceID != nil {
auditFrom = cur.WorkspaceID.String()
}
auditTo := "<none>"
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 := "<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
}
// ===== 任务分解 / 依赖图(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,
})
}