// Package project 内的 repository.go 是 sqlc 生成层之上的薄包装:把 // pgtype.* 与领域类型互转,把 pgx.ErrNoRows 翻译成 errs.NotFound,把 // pg unique_violation(编号竞争)暴露为可识别的 error 让 service 决定重试。 package project import ( "context" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" projectsqlc "github.com/yan1h/agent-coding-workflow/internal/project/sqlc" ) // Repository 是 project 模块对 PG 的全部依赖。所有方法都接受 context 并返回 // 领域类型。Service 层的单元测试通过手写 fakeRepo 满足该接口。 type Repository interface { // Project CreateProject(ctx context.Context, p *Project) (*Project, error) GetProjectBySlug(ctx context.Context, slug string) (*Project, error) GetProjectByID(ctx context.Context, id uuid.UUID) (*Project, error) ListProjects(ctx context.Context, includeArchived bool) ([]*Project, error) UpdateProject(ctx context.Context, p *Project) (*Project, error) ArchiveProject(ctx context.Context, id uuid.UUID) error UnarchiveProject(ctx context.Context, id uuid.UUID) error // ProjectMember 团队/角色 ACL(autonomy roadmap §11) // UpsertMember 新增或更新一名成员的角色(PK 冲突时更新 role)。 UpsertMember(ctx context.Context, projectID, userID uuid.UUID, role Role, addedBy *uuid.UUID) (*Member, error) // DeleteMember 移除一名成员,返回受影响行数(0 表示不存在)。 DeleteMember(ctx context.Context, projectID, userID uuid.UUID) (int64, error) // GetMemberRole 返回 userID 在 projectID 的成员角色;非成员返回 (RoleNone, nil)。 GetMemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) // ListMembers 返回 projectID 的全部成员(按加入时间升序)。 ListMembers(ctx context.Context, projectID uuid.UUID) ([]*Member, error) // Requirement CreateRequirement(ctx context.Context, r *Requirement) (*Requirement, error) GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Requirement, error) GetRequirementByID(ctx context.Context, id uuid.UUID) (*Requirement, error) ListRequirements(ctx context.Context, projectID uuid.UUID, phase Phase, status Status) ([]*Requirement, error) UpdateRequirement(ctx context.Context, r *Requirement) (*Requirement, error) UpdateRequirementPhase(ctx context.Context, id uuid.UUID, to Phase) (*Requirement, error) CloseRequirement(ctx context.Context, id uuid.UUID) error ReopenRequirement(ctx context.Context, id uuid.UUID) error // Issue CreateIssue(ctx context.Context, i *Issue) (*Issue, error) GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Issue, error) GetIssueByID(ctx context.Context, id uuid.UUID) (*Issue, error) ListIssues(ctx context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) ListIssuesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Issue, error) UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID, workspaceID *uuid.UUID) (*Issue, error) UpdateIssueAssignee(ctx context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error) UpdateIssuePriority(ctx context.Context, id uuid.UUID, priority int) (*Issue, error) CloseIssue(ctx context.Context, id uuid.UUID) error ReopenIssue(ctx context.Context, id uuid.UUID) error // Issue 依赖图(autonomy roadmap §10) CreateDependency(ctx context.Context, d *Dependency) (*Dependency, error) DeleteDependency(ctx context.Context, projectID, blockedID, blockerID uuid.UUID) (int64, error) // ListBlockers 返回阻塞 blockedID 的 issue 列表(其依赖)。 ListBlockers(ctx context.Context, blockedID uuid.UUID) ([]*Issue, error) // ListBlocked 返回被 blockerID 阻塞的 issue 列表。 ListBlocked(ctx context.Context, blockerID uuid.UUID) ([]*Issue, error) // ListBlockerIDs 返回 blockedID 的直接 blocker id(一跳),供 service 层环检查。 ListBlockerIDs(ctx context.Context, blockedID uuid.UUID) ([]uuid.UUID, error) // ListSubtasks 返回 parentID 的全部子 issue。 ListSubtasks(ctx context.Context, parentID uuid.UUID) ([]*Issue, error) // ListReadyLeafSubtasks 返回 project 下"就绪可派"的叶子子任务:open、有 parent、 // 全部 blocker 已关闭、无活跃 acp_session、无 open 子。按 priority DESC, number ASC 排序。 ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*Issue, error) // ListSchedulableProjects 返回当前至少有一个就绪叶子子任务的 project id(调度器预筛)。 ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) // CountActiveSessionsForProject 返回 project 内活跃(starting/running)acp_session 数。 CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) // Artifact CreateArtifact(ctx context.Context, a *Artifact) (*Artifact, error) // ListArtifactsByRequirement 的 phase 传零值("")表示不过滤阶段。 ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) GetArtifactByID(ctx context.Context, id uuid.UUID) (*Artifact, error) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error) // ApproveArtifact 给指定产物落 verdict(pass/fail/none),返回更新后的产物。 ApproveArtifact(ctx context.Context, artifactID uuid.UUID, verdict Verdict) (*Artifact, error) // PhasePointer:每 (requirement, phase) 的已批准/当前产物指针。 UpsertPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase, artifactID uuid.UUID, approvedBy uuid.UUID) (*PhasePointer, error) GetPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase) (*PhasePointer, error) ListPhasePointers(ctx context.Context, requirementID uuid.UUID) ([]*PhasePointer, error) } // IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create* // 时碰到此 error 会重试 1 次(编号自增竞争)。 func IsUniqueViolation(err error) bool { var pg *pgconn.PgError return errors.As(err, &pg) && pg.Code == pgerrcode.UniqueViolation } // IsForeignKeyViolation 报告 err 是否为 PG 外键约束冲突。常见场景: // - issues.(project_id, workspace_id) 复合 FK 在跨 project 关联时被 PG 拒绝; // - requirements.workspace_id 关联到不存在的 workspace 时被 PG 拒绝。 // // Repository 层据此把错误翻成 invalid_input(HTTP 400),避免泄漏 5xx。 func IsForeignKeyViolation(err error) bool { var pg *pgconn.PgError return errors.As(err, &pg) && pg.Code == pgerrcode.ForeignKeyViolation } type pgRepo struct{ q *projectsqlc.Queries } // NewPostgresRepository 用现有的 pgxpool 构造 Repository。 func NewPostgresRepository(pool *pgxpool.Pool) Repository { return &pgRepo{q: projectsqlc.New(pool)} } // ===== 类型转换 helper ===== func toPgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} } func toPgUUIDPtr(u *uuid.UUID) pgtype.UUID { if u == nil { return pgtype.UUID{Valid: false} } return pgtype.UUID{Bytes: *u, Valid: true} } func fromPgUUID(p pgtype.UUID) uuid.UUID { if !p.Valid { return uuid.Nil } return uuid.UUID(p.Bytes) } func fromPgUUIDPtr(p pgtype.UUID) *uuid.UUID { if !p.Valid { return nil } u := uuid.UUID(p.Bytes) return &u } func fromPgTimePtr(t pgtype.Timestamptz) *time.Time { if !t.Valid { return nil } v := t.Time return &v } // ===== Project ===== func rowToProject(row projectsqlc.Project) *Project { return &Project{ ID: fromPgUUID(row.ID), Slug: row.Slug, Name: row.Name, Description: row.Description, Visibility: Visibility(row.Visibility), OwnerID: fromPgUUID(row.OwnerID), ArchivedAt: fromPgTimePtr(row.ArchivedAt), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, } } func (r *pgRepo) CreateProject(ctx context.Context, p *Project) (*Project, error) { row, err := r.q.CreateProject(ctx, projectsqlc.CreateProjectParams{ ID: toPgUUID(p.ID), Slug: p.Slug, Name: p.Name, Description: p.Description, Visibility: string(p.Visibility), OwnerID: toPgUUID(p.OwnerID), }) if err != nil { if IsUniqueViolation(err) { return nil, errs.New(errs.CodeProjectSlugTaken, "slug 已被使用") } return nil, errs.Wrap(err, errs.CodeInternal, "create project") } return rowToProject(row), nil } func (r *pgRepo) GetProjectBySlug(ctx context.Context, slug string) (*Project, error) { row, err := r.q.GetProjectBySlug(ctx, slug) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "project 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get project by slug") } return rowToProject(row), nil } func (r *pgRepo) GetProjectByID(ctx context.Context, id uuid.UUID) (*Project, error) { row, err := r.q.GetProjectByID(ctx, toPgUUID(id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "project 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get project by id") } return rowToProject(row), nil } func (r *pgRepo) ListProjects(ctx context.Context, includeArchived bool) ([]*Project, error) { rows, err := r.q.ListProjects(ctx, includeArchived) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list projects") } out := make([]*Project, 0, len(rows)) for _, row := range rows { out = append(out, rowToProject(row)) } return out, nil } func (r *pgRepo) UpdateProject(ctx context.Context, p *Project) (*Project, error) { row, err := r.q.UpdateProject(ctx, projectsqlc.UpdateProjectParams{ ID: toPgUUID(p.ID), Name: p.Name, Description: p.Description, Visibility: string(p.Visibility), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "project 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "update project") } return rowToProject(row), nil } func (r *pgRepo) ArchiveProject(ctx context.Context, id uuid.UUID) error { if err := r.q.ArchiveProject(ctx, toPgUUID(id)); err != nil { return errs.Wrap(err, errs.CodeInternal, "archive project") } return nil } func (r *pgRepo) UnarchiveProject(ctx context.Context, id uuid.UUID) error { if err := r.q.UnarchiveProject(ctx, toPgUUID(id)); err != nil { return errs.Wrap(err, errs.CodeInternal, "unarchive project") } return nil } // ===== ProjectMember ===== func rowToMember(row projectsqlc.ProjectMember) *Member { return &Member{ ProjectID: fromPgUUID(row.ProjectID), UserID: fromPgUUID(row.UserID), Role: Role(row.Role), AddedBy: fromPgUUIDPtr(row.AddedBy), CreatedAt: row.CreatedAt.Time, } } func (r *pgRepo) UpsertMember(ctx context.Context, projectID, userID uuid.UUID, role Role, addedBy *uuid.UUID) (*Member, error) { row, err := r.q.UpsertProjectMember(ctx, projectsqlc.UpsertProjectMemberParams{ ProjectID: toPgUUID(projectID), UserID: toPgUUID(userID), Role: string(role), AddedBy: toPgUUIDPtr(addedBy), }) if err != nil { // FK 冲突:user 不存在 → invalid_input(不泄漏 5xx)。 if IsForeignKeyViolation(err) { return nil, errs.New(errs.CodeInvalidInput, "用户不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "upsert project member") } return rowToMember(row), nil } func (r *pgRepo) DeleteMember(ctx context.Context, projectID, userID uuid.UUID) (int64, error) { n, err := r.q.DeleteProjectMember(ctx, projectsqlc.DeleteProjectMemberParams{ ProjectID: toPgUUID(projectID), UserID: toPgUUID(userID), }) if err != nil { return 0, errs.Wrap(err, errs.CodeInternal, "delete project member") } return n, nil } func (r *pgRepo) GetMemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) { row, err := r.q.GetProjectMember(ctx, projectsqlc.GetProjectMemberParams{ ProjectID: toPgUUID(projectID), UserID: toPgUUID(userID), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return RoleNone, nil } return RoleNone, errs.Wrap(err, errs.CodeInternal, "get project member role") } return Role(row.Role), nil } func (r *pgRepo) ListMembers(ctx context.Context, projectID uuid.UUID) ([]*Member, error) { rows, err := r.q.ListProjectMembers(ctx, toPgUUID(projectID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list project members") } out := make([]*Member, 0, len(rows)) for _, row := range rows { out = append(out, rowToMember(row)) } return out, nil } // ===== Requirement ===== func rowToRequirement(row projectsqlc.Requirement) *Requirement { return &Requirement{ ID: fromPgUUID(row.ID), ProjectID: fromPgUUID(row.ProjectID), WorkspaceID: fromPgUUIDPtr(row.WorkspaceID), Number: int(row.Number), Title: row.Title, Description: row.Description, Phase: Phase(row.Phase), Status: Status(row.Status), OwnerID: fromPgUUID(row.OwnerID), ClosedAt: fromPgTimePtr(row.ClosedAt), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, } } func (r *pgRepo) CreateRequirement(ctx context.Context, in *Requirement) (*Requirement, error) { row, err := r.q.CreateRequirement(ctx, projectsqlc.CreateRequirementParams{ ID: toPgUUID(in.ID), ProjectID: toPgUUID(in.ProjectID), Title: in.Title, Description: in.Description, OwnerID: toPgUUID(in.OwnerID), WorkspaceID: toPgUUIDPtr(in.WorkspaceID), }) if err != nil { if IsUniqueViolation(err) { return nil, err // service 层据此重试一次 } if IsForeignKeyViolation(err) { return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "create requirement") } return rowToRequirement(row), nil } func (r *pgRepo) GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Requirement, error) { row, err := r.q.GetRequirementByNumber(ctx, projectsqlc.GetRequirementByNumberParams{ ProjectID: toPgUUID(projectID), Number: int32(number), //nolint:gosec // number 来自 service 层 number int 字段,原值即来自 DB int32,溢出不可达 }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "requirement 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get requirement by number") } return rowToRequirement(row), nil } func (r *pgRepo) GetRequirementByID(ctx context.Context, id uuid.UUID) (*Requirement, error) { row, err := r.q.GetRequirementByID(ctx, toPgUUID(id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "requirement 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get requirement by id") } return rowToRequirement(row), nil } func (r *pgRepo) ListRequirements(ctx context.Context, projectID uuid.UUID, phase Phase, status Status) ([]*Requirement, error) { var phasePtr, statusPtr *string if phase != "" { s := string(phase) phasePtr = &s } if status != "" { s := string(status) statusPtr = &s } rows, err := r.q.ListRequirements(ctx, projectsqlc.ListRequirementsParams{ ProjectID: toPgUUID(projectID), Phase: phasePtr, Status: statusPtr, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list requirements") } out := make([]*Requirement, 0, len(rows)) for _, row := range rows { out = append(out, rowToRequirement(row)) } return out, nil } func (r *pgRepo) UpdateRequirement(ctx context.Context, in *Requirement) (*Requirement, error) { row, err := r.q.UpdateRequirement(ctx, projectsqlc.UpdateRequirementParams{ ID: toPgUUID(in.ID), Title: in.Title, Description: in.Description, OwnerID: toPgUUID(in.OwnerID), WorkspaceID: toPgUUIDPtr(in.WorkspaceID), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "requirement 不存在") } if IsForeignKeyViolation(err) { return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "update requirement") } return rowToRequirement(row), nil } func (r *pgRepo) UpdateRequirementPhase(ctx context.Context, id uuid.UUID, to Phase) (*Requirement, error) { row, err := r.q.UpdateRequirementPhase(ctx, projectsqlc.UpdateRequirementPhaseParams{ ID: toPgUUID(id), Phase: string(to), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "requirement 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "update requirement phase") } return rowToRequirement(row), nil } func (r *pgRepo) CloseRequirement(ctx context.Context, id uuid.UUID) error { if err := r.q.CloseRequirement(ctx, toPgUUID(id)); err != nil { return errs.Wrap(err, errs.CodeInternal, "close requirement") } return nil } func (r *pgRepo) ReopenRequirement(ctx context.Context, id uuid.UUID) error { if err := r.q.ReopenRequirement(ctx, toPgUUID(id)); err != nil { return errs.Wrap(err, errs.CodeInternal, "reopen requirement") } return nil } // ===== Issue ===== func rowToIssue(row projectsqlc.Issue) *Issue { return &Issue{ ID: fromPgUUID(row.ID), ProjectID: fromPgUUID(row.ProjectID), RequirementID: fromPgUUIDPtr(row.RequirementID), WorkspaceID: fromPgUUIDPtr(row.WorkspaceID), Number: int(row.Number), Title: row.Title, Description: row.Description, Status: Status(row.Status), AssigneeID: fromPgUUIDPtr(row.AssigneeID), CreatedBy: fromPgUUID(row.CreatedBy), ClosedAt: fromPgTimePtr(row.ClosedAt), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, ParentID: fromPgUUIDPtr(row.ParentID), Priority: int(row.Priority), } } func (r *pgRepo) CreateIssue(ctx context.Context, in *Issue) (*Issue, error) { row, err := r.q.CreateIssue(ctx, projectsqlc.CreateIssueParams{ ID: toPgUUID(in.ID), ProjectID: toPgUUID(in.ProjectID), RequirementID: toPgUUIDPtr(in.RequirementID), Title: in.Title, Description: in.Description, AssigneeID: toPgUUIDPtr(in.AssigneeID), CreatedBy: toPgUUID(in.CreatedBy), WorkspaceID: toPgUUIDPtr(in.WorkspaceID), ParentID: toPgUUIDPtr(in.ParentID), Priority: int32(in.Priority), //nolint:gosec // priority 0..3,CHECK 约束兜底 }) if err != nil { if IsUniqueViolation(err) { return nil, err } if IsForeignKeyViolation(err) { return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "create issue") } return rowToIssue(row), nil } func (r *pgRepo) GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Issue, error) { row, err := r.q.GetIssueByNumber(ctx, projectsqlc.GetIssueByNumberParams{ ProjectID: toPgUUID(projectID), Number: int32(number), //nolint:gosec // 同上:number 原始来自 DB int32 }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "issue 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get issue by number") } return rowToIssue(row), nil } func (r *pgRepo) GetIssueByID(ctx context.Context, id uuid.UUID) (*Issue, error) { row, err := r.q.GetIssueByID(ctx, toPgUUID(id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "issue 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get issue by id") } return rowToIssue(row), nil } func (r *pgRepo) ListIssues(ctx context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) { var statusPtr, reqFilter *string if filter.Status != "" { s := string(filter.Status) statusPtr = &s } switch { case filter.Requirement == nil: reqFilter = nil case *filter.Requirement == 0: s := "none" reqFilter = &s default: s := "id" reqFilter = &s } rows, err := r.q.ListIssues(ctx, projectsqlc.ListIssuesParams{ ProjectID: toPgUUID(projectID), Status: statusPtr, RequirementFilter: reqFilter, RequirementID: toPgUUIDPtr(requirementID), AssigneeID: toPgUUIDPtr(filter.AssigneeID), }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list issues") } out := make([]*Issue, 0, len(rows)) for _, row := range rows { out = append(out, rowToIssue(row)) } return out, nil } func (r *pgRepo) ListIssuesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Issue, error) { rows, err := r.q.ListIssuesByRequirement(ctx, toPgUUID(requirementID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list issues by requirement") } out := make([]*Issue, 0, len(rows)) for _, row := range rows { out = append(out, rowToIssue(row)) } return out, nil } func (r *pgRepo) UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID, workspaceID *uuid.UUID) (*Issue, error) { row, err := r.q.UpdateIssueCore(ctx, projectsqlc.UpdateIssueCoreParams{ ID: toPgUUID(id), Title: title, Description: description, RequirementID: toPgUUIDPtr(requirementID), WorkspaceID: toPgUUIDPtr(workspaceID), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "issue 不存在") } if IsForeignKeyViolation(err) { return nil, errs.Wrap(err, errs.CodeInvalidInput, "workspace 与 project 不匹配或不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "update issue core") } return rowToIssue(row), nil } func (r *pgRepo) UpdateIssueAssignee(ctx context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error) { row, err := r.q.UpdateIssueAssignee(ctx, projectsqlc.UpdateIssueAssigneeParams{ ID: toPgUUID(id), AssigneeID: toPgUUIDPtr(assignee), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "issue 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "update issue assignee") } return rowToIssue(row), nil } func (r *pgRepo) CloseIssue(ctx context.Context, id uuid.UUID) error { if err := r.q.CloseIssue(ctx, toPgUUID(id)); err != nil { return errs.Wrap(err, errs.CodeInternal, "close issue") } return nil } func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error { if err := r.q.ReopenIssue(ctx, toPgUUID(id)); err != nil { return errs.Wrap(err, errs.CodeInternal, "reopen issue") } return nil } func (r *pgRepo) UpdateIssuePriority(ctx context.Context, id uuid.UUID, priority int) (*Issue, error) { row, err := r.q.UpdateIssuePriority(ctx, projectsqlc.UpdateIssuePriorityParams{ ID: toPgUUID(id), Priority: int32(priority), //nolint:gosec // priority 0..3,CHECK 约束兜底 }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "issue 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "update issue priority") } return rowToIssue(row), nil } // ===== Issue 依赖图(autonomy roadmap §10)===== func rowToDependency(row projectsqlc.IssueDependency) *Dependency { return &Dependency{ ID: fromPgUUID(row.ID), ProjectID: fromPgUUID(row.ProjectID), BlockedID: fromPgUUID(row.BlockedID), BlockerID: fromPgUUID(row.BlockerID), CreatedBy: fromPgUUID(row.CreatedBy), CreatedAt: row.CreatedAt.Time, } } func (r *pgRepo) CreateDependency(ctx context.Context, d *Dependency) (*Dependency, error) { row, err := r.q.CreateDependency(ctx, projectsqlc.CreateDependencyParams{ ID: toPgUUID(d.ID), ProjectID: toPgUUID(d.ProjectID), BlockedID: toPgUUID(d.BlockedID), BlockerID: toPgUUID(d.BlockerID), CreatedBy: toPgUUID(d.CreatedBy), }) if err != nil { if IsUniqueViolation(err) { return nil, errs.New(errs.CodeInvalidInput, "依赖已存在") } if IsForeignKeyViolation(err) { return nil, errs.Wrap(err, errs.CodeInvalidInput, "依赖两端 issue 不存在或跨 project") } return nil, errs.Wrap(err, errs.CodeInternal, "create dependency") } return rowToDependency(row), nil } func (r *pgRepo) DeleteDependency(ctx context.Context, projectID, blockedID, blockerID uuid.UUID) (int64, error) { n, err := r.q.DeleteDependency(ctx, projectsqlc.DeleteDependencyParams{ ProjectID: toPgUUID(projectID), BlockedID: toPgUUID(blockedID), BlockerID: toPgUUID(blockerID), }) if err != nil { return 0, errs.Wrap(err, errs.CodeInternal, "delete dependency") } return n, nil } func (r *pgRepo) ListBlockers(ctx context.Context, blockedID uuid.UUID) ([]*Issue, error) { rows, err := r.q.ListBlockers(ctx, toPgUUID(blockedID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list blockers") } out := make([]*Issue, 0, len(rows)) for _, row := range rows { out = append(out, rowToIssue(row)) } return out, nil } func (r *pgRepo) ListBlocked(ctx context.Context, blockerID uuid.UUID) ([]*Issue, error) { rows, err := r.q.ListBlocked(ctx, toPgUUID(blockerID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list blocked") } out := make([]*Issue, 0, len(rows)) for _, row := range rows { out = append(out, rowToIssue(row)) } return out, nil } func (r *pgRepo) ListBlockerIDs(ctx context.Context, blockedID uuid.UUID) ([]uuid.UUID, error) { rows, err := r.q.ListBlockerIDs(ctx, toPgUUID(blockedID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list blocker ids") } out := make([]uuid.UUID, 0, len(rows)) for _, p := range rows { out = append(out, fromPgUUID(p)) } return out, nil } func (r *pgRepo) ListSubtasks(ctx context.Context, parentID uuid.UUID) ([]*Issue, error) { rows, err := r.q.ListSubtasks(ctx, toPgUUID(parentID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list subtasks") } out := make([]*Issue, 0, len(rows)) for _, row := range rows { out = append(out, rowToIssue(row)) } return out, nil } func (r *pgRepo) ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*Issue, error) { if limit <= 0 { limit = 50 } rows, err := r.q.ListReadyLeafSubtasks(ctx, projectsqlc.ListReadyLeafSubtasksParams{ ProjectID: toPgUUID(projectID), Limit: int32(limit), //nolint:gosec // limit 来自配置/调用方,正向小整数 }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list ready leaf subtasks") } out := make([]*Issue, 0, len(rows)) for _, row := range rows { out = append(out, rowToIssue(row)) } return out, nil } func (r *pgRepo) ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) { rows, err := r.q.ListSchedulableProjects(ctx) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list schedulable projects") } out := make([]uuid.UUID, 0, len(rows)) for _, p := range rows { out = append(out, fromPgUUID(p)) } return out, nil } func (r *pgRepo) CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) { n, err := r.q.CountActiveSessionsForProject(ctx, toPgUUID(projectID)) if err != nil { return 0, errs.Wrap(err, errs.CodeInternal, "count active sessions for project") } return int(n), nil } // ===== Artifact ===== func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact { return &Artifact{ ID: fromPgUUID(row.ID), RequirementID: fromPgUUID(row.RequirementID), Phase: Phase(row.Phase), Version: int(row.Version), Content: row.Content, Note: row.Note, SourceMessageID: row.SourceMessageID, CreatedBy: fromPgUUID(row.CreatedBy), CreatedAt: row.CreatedAt.Time, Verdict: Verdict(row.Verdict), } } func rowToPhasePointer(row projectsqlc.RequirementPhasePointer) *PhasePointer { return &PhasePointer{ RequirementID: fromPgUUID(row.RequirementID), Phase: Phase(row.Phase), ApprovedArtifactID: fromPgUUIDPtr(row.ApprovedArtifactID), ApprovedBy: fromPgUUIDPtr(row.ApprovedBy), ApprovedAt: fromPgTimePtr(row.ApprovedAt), } } func (r *pgRepo) CreateArtifact(ctx context.Context, in *Artifact) (*Artifact, error) { row, err := r.q.CreateRequirementArtifact(ctx, projectsqlc.CreateRequirementArtifactParams{ ID: toPgUUID(in.ID), RequirementID: toPgUUID(in.RequirementID), Phase: string(in.Phase), Version: int32(in.Version), //nolint:gosec // version 来自 max+1,正常区间不溢出 Content: in.Content, Note: in.Note, SourceMessageID: in.SourceMessageID, CreatedBy: toPgUUID(in.CreatedBy), }) if err != nil { if IsUniqueViolation(err) { return nil, err // service 层据此重试一次 } return nil, errs.Wrap(err, errs.CodeInternal, "create artifact") } return rowToArtifact(row), nil } func (r *pgRepo) ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) { rows, err := r.q.ListRequirementArtifactsByRequirement(ctx, projectsqlc.ListRequirementArtifactsByRequirementParams{ RequirementID: toPgUUID(requirementID), Column2: string(phase), }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list artifacts by requirement") } out := make([]*Artifact, 0, len(rows)) for _, row := range rows { out = append(out, rowToArtifact(row)) } return out, nil } func (r *pgRepo) GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) { row, err := r.q.GetRequirementArtifactByVersion(ctx, projectsqlc.GetRequirementArtifactByVersionParams{ RequirementID: toPgUUID(requirementID), Phase: string(phase), Version: int32(version), //nolint:gosec // version 来自 service 层 int,原值即来自 DB int32 }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "artifact 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get artifact by version") } return rowToArtifact(row), nil } func (r *pgRepo) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error) { max, err := r.q.GetMaxRequirementArtifactVersion(ctx, projectsqlc.GetMaxRequirementArtifactVersionParams{ RequirementID: toPgUUID(requirementID), Phase: string(phase), }) if err != nil { return 0, errs.Wrap(err, errs.CodeInternal, "get max artifact version") } return int(max), nil } func (r *pgRepo) GetArtifactByID(ctx context.Context, id uuid.UUID) (*Artifact, error) { row, err := r.q.GetRequirementArtifactByID(ctx, toPgUUID(id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "artifact 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get artifact by id") } return rowToArtifact(row), nil } func (r *pgRepo) ApproveArtifact(ctx context.Context, artifactID uuid.UUID, verdict Verdict) (*Artifact, error) { row, err := r.q.ApproveRequirementArtifact(ctx, projectsqlc.ApproveRequirementArtifactParams{ ID: toPgUUID(artifactID), Verdict: string(verdict), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "artifact 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "approve artifact") } return rowToArtifact(row), nil } // ===== PhasePointer ===== func (r *pgRepo) UpsertPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase, artifactID, approvedBy uuid.UUID) (*PhasePointer, error) { row, err := r.q.UpsertRequirementPhasePointer(ctx, projectsqlc.UpsertRequirementPhasePointerParams{ RequirementID: toPgUUID(requirementID), Phase: string(phase), ApprovedArtifactID: toPgUUID(artifactID), ApprovedBy: toPgUUID(approvedBy), }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "upsert phase pointer") } return rowToPhasePointer(row), nil } func (r *pgRepo) GetPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase) (*PhasePointer, error) { row, err := r.q.GetRequirementPhasePointer(ctx, projectsqlc.GetRequirementPhasePointerParams{ RequirementID: toPgUUID(requirementID), Phase: string(phase), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "phase pointer 不存在") } return nil, errs.Wrap(err, errs.CodeInternal, "get phase pointer") } return rowToPhasePointer(row), nil } func (r *pgRepo) ListPhasePointers(ctx context.Context, requirementID uuid.UUID) ([]*PhasePointer, error) { rows, err := r.q.ListRequirementPhasePointers(ctx, toPgUUID(requirementID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list phase pointers") } out := make([]*PhasePointer, 0, len(rows)) for _, row := range rows { out = append(out, rowToPhasePointer(row)) } return out, nil }