// repository.go is a thin wrapper over the sqlc layer: pgtype<->domain // conversion, pgx.ErrNoRows -> errs.NotFound translation, and unique-violation // detection. Mirrors internal/workspace/repository.go. package changerequest 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" changerequestsqlc "github.com/yan1h/agent-coding-workflow/internal/changerequest/sqlc" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // Tx is the transaction handle exposed to the service: NextNumber + Create run // in one tx so per-project numbering is serialized. type Tx interface { NextNumber(ctx context.Context, projectID uuid.UUID) (int, error) Create(ctx context.Context, cr *ChangeRequest) (*ChangeRequest, error) } // Repository is the change-request module's PG dependency. type Repository interface { GetByID(ctx context.Context, id uuid.UUID) (*ChangeRequest, error) GetByNumber(ctx context.Context, projectSlug string, number int) (*ChangeRequest, error) ListByWorkspace(ctx context.Context, wsID uuid.UUID) ([]*ChangeRequest, error) ListByProject(ctx context.Context, projectID uuid.UUID) ([]*ChangeRequest, error) UpdateReview(ctx context.Context, id uuid.UUID, verdict Verdict, reviewedBy uuid.UUID) (*ChangeRequest, error) UpdateState(ctx context.Context, id uuid.UUID, st State, mergeSHA string, mergedAt *time.Time) (*ChangeRequest, error) UpdateCI(ctx context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error) InTx(ctx context.Context, fn func(Tx) error) error } // IsUniqueViolation reports whether err is a PG unique constraint violation. func IsUniqueViolation(err error) bool { var pg *pgconn.PgError return errors.As(err, &pg) && pg.Code == pgerrcode.UniqueViolation } type pgRepo struct { pool *pgxpool.Pool q *changerequestsqlc.Queries } // NewPostgresRepository builds a Repository from a pgxpool. func NewPostgresRepository(pool *pgxpool.Pool) Repository { return &pgRepo{pool: pool, q: changerequestsqlc.New(pool)} } // ===== conversion helpers ===== 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 toPgTimePtr(t *time.Time) pgtype.Timestamptz { if t == nil { return pgtype.Timestamptz{Valid: false} } return pgtype.Timestamptz{Time: *t, Valid: true} } func fromPgTimePtr(t pgtype.Timestamptz) *time.Time { if !t.Valid { return nil } v := t.Time return &v } func rowToCR(r changerequestsqlc.ChangeRequest) *ChangeRequest { return &ChangeRequest{ ID: fromPgUUID(r.ID), ProjectID: fromPgUUID(r.ProjectID), WorkspaceID: fromPgUUID(r.WorkspaceID), RequirementID: fromPgUUIDPtr(r.RequirementID), IssueID: fromPgUUIDPtr(r.IssueID), Number: int(r.Number), Title: r.Title, Description: r.Description, SourceBranch: r.SourceBranch, TargetBranch: r.TargetBranch, State: State(r.State), ReviewVerdict: Verdict(r.ReviewVerdict), CIState: CIState(r.CiState), Provider: r.Provider, ExternalID: r.ExternalID, ExternalURL: r.ExternalUrl, MergeCommitSHA: r.MergeCommitSha, ReviewedBy: fromPgUUIDPtr(r.ReviewedBy), ReviewedAt: fromPgTimePtr(r.ReviewedAt), CreatedBy: fromPgUUID(r.CreatedBy), MergedAt: fromPgTimePtr(r.MergedAt), CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time, } } // ===== queries ===== func (r *pgRepo) GetByID(ctx context.Context, id uuid.UUID) (*ChangeRequest, error) { row, err := r.q.GetChangeRequestByID(ctx, toPgUUID(id)) if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "change request not found") } if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "get change request") } return rowToCR(row), nil } func (r *pgRepo) GetByNumber(ctx context.Context, projectSlug string, number int) (*ChangeRequest, error) { row, err := r.q.GetChangeRequestByNumber(ctx, changerequestsqlc.GetChangeRequestByNumberParams{ Slug: projectSlug, Number: int32(number), }) if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "change request not found") } if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "get change request by number") } return rowToCR(row), nil } func (r *pgRepo) ListByWorkspace(ctx context.Context, wsID uuid.UUID) ([]*ChangeRequest, error) { rows, err := r.q.ListChangeRequestsByWorkspace(ctx, toPgUUID(wsID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list change requests by workspace") } out := make([]*ChangeRequest, 0, len(rows)) for _, row := range rows { out = append(out, rowToCR(row)) } return out, nil } func (r *pgRepo) ListByProject(ctx context.Context, projectID uuid.UUID) ([]*ChangeRequest, error) { rows, err := r.q.ListChangeRequestsByProject(ctx, toPgUUID(projectID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list change requests by project") } out := make([]*ChangeRequest, 0, len(rows)) for _, row := range rows { out = append(out, rowToCR(row)) } return out, nil } func (r *pgRepo) UpdateReview(ctx context.Context, id uuid.UUID, verdict Verdict, reviewedBy uuid.UUID) (*ChangeRequest, error) { row, err := r.q.UpdateChangeRequestReview(ctx, changerequestsqlc.UpdateChangeRequestReviewParams{ ID: toPgUUID(id), ReviewVerdict: string(verdict), ReviewedBy: toPgUUID(reviewedBy), }) if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "change request not found") } if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "update change request review") } return rowToCR(row), nil } func (r *pgRepo) UpdateState(ctx context.Context, id uuid.UUID, st State, mergeSHA string, mergedAt *time.Time) (*ChangeRequest, error) { row, err := r.q.UpdateChangeRequestState(ctx, changerequestsqlc.UpdateChangeRequestStateParams{ ID: toPgUUID(id), State: string(st), MergeCommitSha: mergeSHA, MergedAt: toPgTimePtr(mergedAt), }) if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "change request not found") } if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "update change request state") } return rowToCR(row), nil } func (r *pgRepo) UpdateCI(ctx context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error) { row, err := r.q.UpdateChangeRequestCI(ctx, changerequestsqlc.UpdateChangeRequestCIParams{ ID: toPgUUID(id), CiState: string(ci), State: string(st), }) if errors.Is(err, pgx.ErrNoRows) { return nil, errs.New(errs.CodeNotFound, "change request not found") } if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "update change request ci") } return rowToCR(row), nil } func (r *pgRepo) InTx(ctx context.Context, fn func(Tx) error) error { return pgx.BeginFunc(ctx, r.pool, func(tx pgx.Tx) error { return fn(&pgTx{q: r.q.WithTx(tx)}) }) } type pgTx struct{ q *changerequestsqlc.Queries } func (t *pgTx) NextNumber(ctx context.Context, projectID uuid.UUID) (int, error) { n, err := t.q.NextChangeRequestNumber(ctx, toPgUUID(projectID)) if err != nil { return 0, errs.Wrap(err, errs.CodeInternal, "next change request number") } return int(n), nil } func (t *pgTx) Create(ctx context.Context, cr *ChangeRequest) (*ChangeRequest, error) { row, err := t.q.CreateChangeRequest(ctx, changerequestsqlc.CreateChangeRequestParams{ ID: toPgUUID(cr.ID), ProjectID: toPgUUID(cr.ProjectID), WorkspaceID: toPgUUID(cr.WorkspaceID), RequirementID: toPgUUIDPtr(cr.RequirementID), IssueID: toPgUUIDPtr(cr.IssueID), Number: int32(cr.Number), Title: cr.Title, Description: cr.Description, SourceBranch: cr.SourceBranch, TargetBranch: cr.TargetBranch, CiState: string(cr.CIState), Provider: cr.Provider, ExternalID: cr.ExternalID, ExternalUrl: cr.ExternalURL, CreatedBy: toPgUUID(cr.CreatedBy), }) if err != nil { if IsUniqueViolation(err) { return nil, errs.Wrap(err, errs.CodeConflict, "change request number already used") } return nil, errs.Wrap(err, errs.CodeInternal, "create change request") } return rowToCR(row), nil }