package store import ( "context" "embed" "fmt" "sort" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "hopweb/internal/gitea" "hopweb/internal/ids" ) //go:embed migrations/*.sql var migrations embed.FS type Repository struct { ID string `json:"id"` GiteaID int64 `json:"gitea_id"` Owner string `json:"owner"` Name string `json:"name"` FullName string `json:"full_name"` CloneURL string `json:"clone_url"` DefaultBranch string `json:"default_branch"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type WebhookDelivery struct { DeliveryID string Event string EventType string PayloadSHA256 string Repository *gitea.Repository } type Store interface { Ping(context.Context) error ListRepositories(context.Context) ([]Repository, error) UpsertRepository(context.Context, gitea.Repository) (Repository, error) RecordWebhook(context.Context, WebhookDelivery) (bool, error) } type Postgres struct { pool *pgxpool.Pool } func Open(ctx context.Context, databaseURL string) (*Postgres, error) { pool, err := pgxpool.New(ctx, databaseURL) if err != nil { return nil, err } if err := pool.Ping(ctx); err != nil { pool.Close() return nil, err } return &Postgres{pool: pool}, nil } func (p *Postgres) Close() { p.pool.Close() } func (p *Postgres) Ping(ctx context.Context) error { return p.pool.Ping(ctx) } func (p *Postgres) Migrate(ctx context.Context) error { if _, err := p.pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { return err } entries, err := migrations.ReadDir("migrations") if err != nil { return err } sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) for _, entry := range entries { name := entry.Name() var exists bool if err := p.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE name=$1)`, name).Scan(&exists); err != nil { return err } if exists { continue } sql, err := migrations.ReadFile("migrations/" + name) if err != nil { return err } tx, err := p.pool.Begin(ctx) if err != nil { return err } if _, err = tx.Exec(ctx, string(sql)); err == nil { _, err = tx.Exec(ctx, `INSERT INTO schema_migrations (name) VALUES ($1)`, name) } if err != nil { _ = tx.Rollback(ctx) return fmt.Errorf("apply migration %s: %w", name, err) } if err := tx.Commit(ctx); err != nil { return err } } return nil } func (p *Postgres) ListRepositories(ctx context.Context) ([]Repository, error) { rows, err := p.pool.Query(ctx, `SELECT id, gitea_id, owner, name, full_name, clone_url, default_branch, created_at, updated_at FROM repositories ORDER BY full_name`) if err != nil { return nil, err } defer rows.Close() repositories := make([]Repository, 0) for rows.Next() { var repository Repository if err := rows.Scan(&repository.ID, &repository.GiteaID, &repository.Owner, &repository.Name, &repository.FullName, &repository.CloneURL, &repository.DefaultBranch, &repository.CreatedAt, &repository.UpdatedAt); err != nil { return nil, err } repositories = append(repositories, repository) } return repositories, rows.Err() } func (p *Postgres) UpsertRepository(ctx context.Context, repository gitea.Repository) (Repository, error) { id, err := ids.Repository() if err != nil { return Repository{}, err } return upsertRepository(ctx, p.pool, id, repository) } type querier interface { QueryRow(context.Context, string, ...any) pgx.Row } func upsertRepository(ctx context.Context, q querier, id string, repository gitea.Repository) (Repository, error) { owner := repository.OwnerName() fullName := repository.FullName if fullName == "" { fullName = owner + "/" + repository.Name } var saved Repository err := q.QueryRow(ctx, ` INSERT INTO repositories (id, gitea_id, owner, name, full_name, clone_url, default_branch) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (gitea_id) DO UPDATE SET owner=EXCLUDED.owner, name=EXCLUDED.name, full_name=EXCLUDED.full_name, clone_url=EXCLUDED.clone_url, default_branch=EXCLUDED.default_branch, updated_at=now() RETURNING id, gitea_id, owner, name, full_name, clone_url, default_branch, created_at, updated_at`, id, repository.ID, owner, repository.Name, fullName, repository.CloneURL, repository.DefaultBranch, ).Scan(&saved.ID, &saved.GiteaID, &saved.Owner, &saved.Name, &saved.FullName, &saved.CloneURL, &saved.DefaultBranch, &saved.CreatedAt, &saved.UpdatedAt) return saved, err } func (p *Postgres) RecordWebhook(ctx context.Context, delivery WebhookDelivery) (bool, error) { tx, err := p.pool.Begin(ctx) if err != nil { return false, err } defer func() { _ = tx.Rollback(ctx) }() result, err := tx.Exec(ctx, `INSERT INTO webhook_deliveries (delivery_id, event, event_type, payload_sha256) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, delivery.DeliveryID, delivery.Event, delivery.EventType, delivery.PayloadSHA256) if err != nil { return false, err } if result.RowsAffected() == 0 { var existingEvent, existingEventType, existingDigest string if err := tx.QueryRow(ctx, `SELECT event, event_type, payload_sha256 FROM webhook_deliveries WHERE delivery_id=$1`, delivery.DeliveryID).Scan(&existingEvent, &existingEventType, &existingDigest); err != nil { return false, err } if existingEvent != delivery.Event || existingEventType != delivery.EventType || existingDigest != delivery.PayloadSHA256 { return false, fmt.Errorf("webhook delivery ID %q was reused with different content", delivery.DeliveryID) } return true, nil } if delivery.Repository != nil && delivery.Repository.ID > 0 { id, err := ids.Repository() if err != nil { return false, err } if _, err := upsertRepository(ctx, tx, id, *delivery.Repository); err != nil { return false, err } } if err := tx.Commit(ctx); err != nil { return false, err } return false, nil } var _ Store = (*Postgres)(nil)