Files
agentic-coding-workflow/internal/project/repository.go
T
2026-06-10 17:53:09 +08:00

578 lines
20 KiB
Go

// 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
// 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)
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)
CloseIssue(ctx context.Context, id uuid.UUID) error
ReopenIssue(ctx context.Context, id uuid.UUID) 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)
GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, 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
}
// ===== 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,
}
}
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),
})
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) 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
}
// ===== 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,
}
}
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
}