Add the runnable Gitea, PostgreSQL, and Hop control-plane foundation

Hop-State: A_06FN3QYA1N56NQYGZ81DWGR
Hop-Proposal: R_06FN3QXJRYPPEQZQA6S69QG
Hop-Task: T_06FN3MVGY3MT82ESQ89BND0
Hop-Attempt: AT_06FN3MVGY092BFA5MR9C7EG
This commit is contained in:
Hop
2026-07-11 08:50:48 -07:00
parent dd2c0abfd2
commit d9544b435b
22 changed files with 1114 additions and 0 deletions
@@ -0,0 +1,191 @@
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)