diff --git a/go.mod b/go.mod index 4f7ec3e..75afb77 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/go.sum b/go.sum index ac80681..00a85d2 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/internal/project/repository.go b/internal/project/repository.go new file mode 100644 index 0000000..bed7a16 --- /dev/null +++ b/internal/project/repository.go @@ -0,0 +1,465 @@ +// 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) (*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 +} + +// 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 +} + +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), + 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), + }) + if err != nil { + if IsUniqueViolation(err) { + return nil, err // service 层据此重试一次 + } + 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), + }) + 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), + }) + 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") + } + 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), + 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), + }) + if err != nil { + if IsUniqueViolation(err) { + return nil, err + } + 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), + }) + 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) (*Issue, error) { + row, err := r.q.UpdateIssueCore(ctx, projectsqlc.UpdateIssueCoreParams{ + ID: toPgUUID(id), + Title: title, + Description: description, + RequirementID: toPgUUIDPtr(requirementID), + }) + 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 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 +}