The code and data behind xeiaso.net
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(cmd/sponsor-panel): add background sponsor sync with pgxpool (#1130)

* feat(cmd/sponsor-panel): add background sponsor sync with pgxpool

Replace database/sql with pgxpool for better connection management and
add a background sync loop that periodically syncs all GitHub sponsors
to a local database table. This enables faster sponsorship lookups
without hitting the GitHub API for every request.

- Migrate from database/sql to pgx/v5/pgxpool for connection pooling
- Add github_sponsor_usernames table for synced sponsor data
- Add syncSponsors and startSyncLoop for hourly background sync
- Update fetchSponsorship to check synced table before API calls

* feat(cmd/sponsor-panel): make sponsor target configurable via flag

Add --sponsor-target flag (default: "Xe") to allow configuring which
GitHub user's sponsorships to sync, addressing hardcoded value concern
from code review.

authored by

Xe Iaso and committed by
GitHub
2ae28adc 061445eb

+387 -53
+1 -1
cmd/sponsor-panel/handlers.go
··· 278 278 GitHubIssueURL: createdIssue.GetHTMLURL(), 279 279 GitHubIssueNumber: createdIssue.GetNumber(), 280 280 } 281 - if err := createLogoSubmission(s.db, submission); err != nil { 281 + if err := createLogoSubmission(r.Context(), s.pool, submission); err != nil { 282 282 slog.Error("logoHandler: failed to store submission", "err", err, "user_id", user.ID, "issue_number", createdIssue.GetNumber()) 283 283 } else { 284 284 slog.Debug("logoHandler: submission stored in database", "user_id", user.ID, "issue_number", createdIssue.GetNumber())
+16 -9
cmd/sponsor-panel/main.go
··· 3 3 import ( 4 4 "context" 5 5 "crypto/rand" 6 - "database/sql" 7 6 "embed" 8 7 "encoding/base64" 9 8 "flag" ··· 20 19 "github.com/facebookgo/flagenv" 21 20 gh "github.com/google/go-github/v82/github" 22 21 "github.com/gorilla/sessions" 23 - _ "github.com/jackc/pgx/v5/stdlib" 22 + "github.com/jackc/pgx/v5/pgxpool" 24 23 _ "github.com/joho/godotenv/autoload" 25 24 "github.com/prometheus/client_golang/prometheus/promhttp" 26 25 "golang.org/x/oauth2" ··· 40 39 cookieSecure = flag.Bool("cookie-secure", true, "Set Secure flag on cookies (enable for HTTPS)") 41 40 bucketName = flag.String("bucket-name", "", "S3 bucket name for logo storage") 42 41 logoSubmissionRepo = flag.String("logo-submission-repo", "anubis", "Repo to submit logo requests to") 42 + sponsorTarget = flag.String("sponsor-target", "Xe", "GitHub username to sync sponsorships for") 43 43 44 44 // OAuth configuration 45 45 clientID = flag.String("github-client-id", "", "GitHub OAuth Client ID") ··· 52 52 53 53 // Server holds the application dependencies. 54 54 type Server struct { 55 - db *sql.DB 55 + pool *pgxpool.Pool 56 56 ghClient *gh.Client 57 57 oauth *oauth2.Config 58 58 discordInvite string ··· 135 135 136 136 // Connect to database 137 137 slog.Debug("main: connecting to database") 138 - db, err := sql.Open("pgx", *databaseURL) 138 + ctx := context.Background() 139 + pool, err := pgxpool.New(ctx, *databaseURL) 139 140 if err != nil { 140 - slog.Error("failed to open database", "err", err) 141 + slog.Error("failed to create connection pool", "err", err) 141 142 os.Exit(1) 142 143 } 143 - defer db.Close() 144 + defer pool.Close() 144 145 145 - if err := db.Ping(); err != nil { 146 + if err := pool.Ping(ctx); err != nil { 146 147 slog.Error("failed to ping database", "err", err) 147 148 os.Exit(1) 148 149 } ··· 150 151 151 152 // Run migrations 152 153 slog.Debug("main: running migrations") 153 - if err := runMigrations(db); err != nil { 154 + if err := runMigrations(ctx, pool); err != nil { 154 155 slog.Error("failed to run migrations", "err", err) 155 156 os.Exit(1) 156 157 } 157 158 slog.Info("main: migrations completed") 159 + 160 + // Start sponsor sync loop in background 161 + syncCtx, syncCancel := context.WithCancel(context.Background()) 162 + defer syncCancel() 163 + go startSyncLoop(syncCtx, pool, *githubToken) 164 + slog.Info("main: sponsor sync loop started") 158 165 159 166 // Create GitHub client 160 167 slog.Debug("main: creating GitHub client") ··· 208 215 } 209 216 210 217 server := &Server{ 211 - db: db, 218 + pool: pool, 212 219 ghClient: ghClient, 213 220 oauth: oauthConfig, 214 221 discordInvite: *discordInvite,
+20 -3
cmd/sponsor-panel/migrations.go
··· 1 1 package main 2 2 3 3 import ( 4 - "database/sql" 4 + "context" 5 5 "log/slog" 6 + 7 + "github.com/jackc/pgx/v5/pgxpool" 6 8 ) 7 9 8 10 const migrationSchema = ` ··· 42 44 CREATE INDEX IF NOT EXISTS idx_users_github_id ON users(github_id); 43 45 CREATE INDEX IF NOT EXISTS idx_users_login ON users(login); 44 46 CREATE INDEX IF NOT EXISTS idx_logo_user_id ON logo_submissions(user_id); 47 + 48 + -- GitHub sponsor usernames: synced list of all sponsors (users + orgs) 49 + CREATE TABLE IF NOT EXISTS github_sponsor_usernames ( 50 + id SERIAL PRIMARY KEY, 51 + username TEXT NOT NULL UNIQUE, -- GitHub login (user or org) 52 + entity_type TEXT NOT NULL, -- 'User' or 'Organization' 53 + monthly_amount_cents INTEGER DEFAULT 0, -- Sponsorship tier amount 54 + tier_name TEXT, -- Tier name for display 55 + is_active BOOLEAN DEFAULT TRUE, -- Active sponsorship flag 56 + synced_at TIMESTAMP DEFAULT NOW(), -- Last sync timestamp 57 + created_at TIMESTAMP DEFAULT NOW() 58 + ); 59 + 60 + CREATE INDEX IF NOT EXISTS idx_sponsor_usernames ON github_sponsor_usernames(username); 61 + CREATE INDEX IF NOT EXISTS idx_sponsor_active ON github_sponsor_usernames(is_active); 45 62 ` 46 63 47 64 // runMigrations executes the database schema migration. 48 - func runMigrations(db *sql.DB) error { 65 + func runMigrations(ctx context.Context, pool *pgxpool.Pool) error { 49 66 slog.Info("running database migrations") 50 - _, err := db.Exec(migrationSchema) 67 + _, err := pool.Exec(ctx, migrationSchema) 51 68 if err != nil { 52 69 return err 53 70 }
+121 -33
cmd/sponsor-panel/models.go
··· 1 1 package main 2 2 3 3 import ( 4 - "database/sql" 4 + "context" 5 5 "encoding/json" 6 6 "log/slog" 7 7 "time" 8 + 9 + "github.com/jackc/pgx/v5/pgxpool" 8 10 ) 9 11 10 12 // User represents a GitHub user with cached sponsorship data. 11 - // This is the simplified model from SPEC.md using sqlx (not GORM). 12 13 type User struct { 13 14 ID int `json:"id" db:"id"` 14 15 GitHubID int64 `json:"github_id" db:"github_id"` ··· 66 67 SubmittedAt time.Time `json:"submitted_at" db:"submitted_at"` 67 68 } 68 69 70 + // SponsorUsername represents a synced sponsor username (user or org). 71 + type SponsorUsername struct { 72 + ID int `json:"id" db:"id"` 73 + Username string `json:"username" db:"username"` 74 + EntityType string `json:"entity_type" db:"entity_type"` 75 + MonthlyAmountCents int `json:"monthly_amount_cents" db:"monthly_amount_cents"` 76 + TierName string `json:"tier_name" db:"tier_name"` 77 + IsActive bool `json:"is_active" db:"is_active"` 78 + SyncedAt time.Time `json:"synced_at" db:"synced_at"` 79 + CreatedAt time.Time `json:"created_at" db:"created_at"` 80 + } 81 + 69 82 // getUserByID retrieves a user by ID from the database. 70 - func getUserByID(db *sql.DB, userID int) (*User, error) { 83 + func getUserByID(ctx context.Context, pool *pgxpool.Pool, userID int) (*User, error) { 71 84 slog.Debug("getUserByID: querying user", "user_id", userID) 72 85 73 86 var user User 74 - err := db.QueryRow(` 87 + err := pool.QueryRow(ctx, ` 75 88 SELECT id, github_id, login, avatar_url, name, email, 76 89 sponsorship_data, last_sponsorship_check, created_at, updated_at 77 90 FROM users WHERE id = $1 ··· 86 99 } 87 100 88 101 slog.Debug("getUserByID: user found", "user_id", userID, "login", user.Login) 89 - return &user, nil 90 - } 91 - 92 - // getUserByGitHubID retrieves a user by GitHub ID from the database. 93 - func getUserByGitHubID(db *sql.DB, githubID int64) (*User, error) { 94 - var user User 95 - err := db.QueryRow(` 96 - SELECT id, github_id, login, avatar_url, name, email, 97 - sponsorship_data, last_sponsorship_check, created_at, updated_at 98 - FROM users WHERE github_id = $1 99 - `, githubID).Scan( 100 - &user.ID, &user.GitHubID, &user.Login, &user.AvatarURL, 101 - &user.Name, &user.Email, &user.SponsorshipData, 102 - &user.LastSponsorshipCheck, &user.CreatedAt, &user.UpdatedAt, 103 - ) 104 - if err != nil { 105 - return nil, err 106 - } 107 102 return &user, nil 108 103 } 109 104 110 105 // upsertUser creates or updates a user in the database. 111 - func upsertUser(db *sql.DB, user *User) error { 106 + func upsertUser(ctx context.Context, pool *pgxpool.Pool, user *User) error { 112 107 slog.Debug("upsertUser: attempting upsert", "github_id", user.GitHubID, "login", user.Login) 113 108 114 109 // Try update first 115 - result, err := db.Exec(` 110 + tag, err := pool.Exec(ctx, ` 116 111 UPDATE users 117 112 SET login=$1, avatar_url=$2, name=$3, email=$4, 118 113 sponsorship_data=$5, last_sponsorship_check=NOW(), updated_at=NOW() ··· 124 119 return err 125 120 } 126 121 127 - rows, _ := result.RowsAffected() 128 - if rows > 0 { 129 - slog.Debug("upsertUser: updated existing user", "github_id", user.GitHubID, "rows_affected", rows) 130 - // Fetch the updated user 131 - return db.QueryRow(` 122 + if tag.RowsAffected() > 0 { 123 + slog.Debug("upsertUser: updated existing user", "github_id", user.GitHubID, "rows_affected", tag.RowsAffected()) 124 + return pool.QueryRow(ctx, ` 132 125 SELECT id, github_id, login, avatar_url, name, email, 133 126 sponsorship_data, last_sponsorship_check, created_at, updated_at 134 127 FROM users WHERE github_id = $1 ··· 141 134 142 135 slog.Debug("upsertUser: inserting new user", "github_id", user.GitHubID, "login", user.Login) 143 136 144 - // Insert new user 145 - return db.QueryRow(` 137 + return pool.QueryRow(ctx, ` 146 138 INSERT INTO users (github_id, login, avatar_url, name, email, sponsorship_data) 147 139 VALUES ($1, $2, $3, $4, $5, $6) 148 140 RETURNING id, github_id, login, avatar_url, name, email, ··· 156 148 } 157 149 158 150 // createLogoSubmission creates a logo submission in the database. 159 - func createLogoSubmission(db *sql.DB, submission *LogoSubmission) error { 151 + func createLogoSubmission(ctx context.Context, pool *pgxpool.Pool, submission *LogoSubmission) error { 160 152 slog.Debug("createLogoSubmission: inserting submission", 161 153 "user_id", submission.UserID, 162 154 "company", submission.CompanyName, 163 155 "issue_number", submission.GitHubIssueNumber) 164 156 165 - err := db.QueryRow(` 157 + err := pool.QueryRow(ctx, ` 166 158 INSERT INTO logo_submissions (user_id, company_name, website, logo_url, github_issue_url, github_issue_number) 167 159 VALUES ($1, $2, $3, $4, $5, $6) 168 160 RETURNING id, submitted_at ··· 182 174 183 175 return nil 184 176 } 177 + 178 + // getActiveSponsorsByUsernames returns active sponsors matching any of the given usernames. 179 + func getActiveSponsorsByUsernames(ctx context.Context, pool *pgxpool.Pool, usernames []string) ([]*SponsorUsername, error) { 180 + if len(usernames) == 0 { 181 + return nil, nil 182 + } 183 + 184 + slog.Debug("getActiveSponsorsByUsernames: querying sponsors", "usernames", usernames) 185 + 186 + rows, err := pool.Query(ctx, ` 187 + SELECT id, username, entity_type, monthly_amount_cents, tier_name, is_active, synced_at, created_at 188 + FROM github_sponsor_usernames 189 + WHERE username = ANY($1) AND is_active = TRUE 190 + ORDER BY monthly_amount_cents DESC 191 + `, usernames) 192 + if err != nil { 193 + slog.Error("getActiveSponsorsByUsernames: query failed", "err", err) 194 + return nil, err 195 + } 196 + defer rows.Close() 197 + 198 + var sponsors []*SponsorUsername 199 + for rows.Next() { 200 + s := &SponsorUsername{} 201 + if err := rows.Scan(&s.ID, &s.Username, &s.EntityType, &s.MonthlyAmountCents, &s.TierName, &s.IsActive, &s.SyncedAt, &s.CreatedAt); err != nil { 202 + slog.Error("getActiveSponsorsByUsernames: scan failed", "err", err) 203 + return nil, err 204 + } 205 + sponsors = append(sponsors, s) 206 + } 207 + 208 + slog.Debug("getActiveSponsorsByUsernames: found sponsors", "count", len(sponsors)) 209 + return sponsors, nil 210 + } 211 + 212 + // upsertSponsorUsername inserts or updates a sponsor username. 213 + func upsertSponsorUsername(ctx context.Context, pool *pgxpool.Pool, sponsor *SponsorUsername) error { 214 + slog.Debug("upsertSponsorUsername: upserting sponsor", 215 + "username", sponsor.Username, 216 + "entity_type", sponsor.EntityType, 217 + "monthly_amount_cents", sponsor.MonthlyAmountCents, 218 + "tier_name", sponsor.TierName) 219 + 220 + tag, err := pool.Exec(ctx, ` 221 + UPDATE github_sponsor_usernames 222 + SET entity_type=$1, monthly_amount_cents=$2, tier_name=$3, is_active=$4, synced_at=NOW() 223 + WHERE username=$5 224 + `, sponsor.EntityType, sponsor.MonthlyAmountCents, sponsor.TierName, sponsor.IsActive, sponsor.Username) 225 + if err != nil { 226 + slog.Error("upsertSponsorUsername: update failed", "err", err, "username", sponsor.Username) 227 + return err 228 + } 229 + 230 + if tag.RowsAffected() > 0 { 231 + slog.Debug("upsertSponsorUsername: updated existing sponsor", "username", sponsor.Username) 232 + return pool.QueryRow(ctx, `SELECT id, created_at FROM github_sponsor_usernames WHERE username = $1`, sponsor.Username).Scan(&sponsor.ID, &sponsor.CreatedAt) 233 + } 234 + 235 + err = pool.QueryRow(ctx, ` 236 + INSERT INTO github_sponsor_usernames (username, entity_type, monthly_amount_cents, tier_name, is_active) 237 + VALUES ($1, $2, $3, $4, $5) 238 + RETURNING id, synced_at, created_at 239 + `, sponsor.Username, sponsor.EntityType, sponsor.MonthlyAmountCents, sponsor.TierName, sponsor.IsActive).Scan(&sponsor.ID, &sponsor.SyncedAt, &sponsor.CreatedAt) 240 + if err != nil { 241 + slog.Error("upsertSponsorUsername: insert failed", "err", err, "username", sponsor.Username) 242 + return err 243 + } 244 + 245 + slog.Debug("upsertSponsorUsername: inserted new sponsor", "username", sponsor.Username, "id", sponsor.ID) 246 + return nil 247 + } 248 + 249 + // markInactiveSponsorsNotIn marks all sponsors as inactive that are not in the given usernames list. 250 + func markInactiveSponsorsNotIn(ctx context.Context, pool *pgxpool.Pool, usernames []string) (int64, error) { 251 + if len(usernames) == 0 { 252 + tag, err := pool.Exec(ctx, `UPDATE github_sponsor_usernames SET is_active = FALSE WHERE is_active = TRUE`) 253 + if err != nil { 254 + return 0, err 255 + } 256 + slog.Debug("markInactiveSponsorsNotIn: marked all sponsors inactive", "count", tag.RowsAffected()) 257 + return tag.RowsAffected(), nil 258 + } 259 + 260 + tag, err := pool.Exec(ctx, ` 261 + UPDATE github_sponsor_usernames 262 + SET is_active = FALSE 263 + WHERE NOT (username = ANY($1)) AND is_active = TRUE 264 + `, usernames) 265 + if err != nil { 266 + slog.Error("markInactiveSponsorsNotIn: update failed", "err", err) 267 + return 0, err 268 + } 269 + 270 + slog.Debug("markInactiveSponsorsNotIn: marked sponsors inactive", "count", tag.RowsAffected()) 271 + return tag.RowsAffected(), nil 272 + }
+39 -7
cmd/sponsor-panel/oauth.go
··· 12 12 "strings" 13 13 14 14 "github.com/gorilla/sessions" 15 + "github.com/jackc/pgx/v5/pgxpool" 15 16 "xeiaso.net/v4/cmd/sponsor-panel/templates" 16 17 ) 17 18 ··· 364 365 } 365 366 366 367 // fetchSponsorship fetches sponsorship data from GitHub GraphQL API. 367 - // It checks the explicit allowlist first, then direct user sponsorship, then organizational membership. 368 - func fetchSponsorship(ctx context.Context, token string, userLogin string, userOrgs map[string]bool, userOrgsWithSponsorship map[string]*sponsorshipInfo, fiftyPlusSponsors map[string]bool) (string, error) { 368 + // It checks the explicit allowlist first, then the synced sponsor table, then direct user sponsorship, then organizational membership. 369 + func fetchSponsorship(ctx context.Context, pool *pgxpool.Pool, token string, userLogin string, userOrgs map[string]bool, userOrgsWithSponsorship map[string]*sponsorshipInfo, fiftyPlusSponsors map[string]bool) (string, error) { 369 370 slog.Debug("fetchSponsorship: checking sponsorship", "user", userLogin) 370 371 371 372 // Check if user is in the fifty-plus sponsors list first (highest priority) ··· 392 393 } 393 394 } 394 395 396 + // Check synced sponsor table for user or their orgs 397 + usernames := make([]string, 0, 1+len(userOrgs)) 398 + usernames = append(usernames, userLogin) 399 + for org := range userOrgs { 400 + usernames = append(usernames, org) 401 + } 402 + 403 + syncedSponsors, err := getActiveSponsorsByUsernames(ctx, pool, usernames) 404 + if err != nil { 405 + slog.Warn("fetchSponsorship: failed to check synced sponsors table", "err", err) 406 + } else if len(syncedSponsors) > 0 { 407 + // Return the highest tier sponsor (already sorted by monthly_amount_cents DESC) 408 + sponsor := syncedSponsors[0] 409 + tierName := sponsor.TierName 410 + if sponsor.Username != userLogin { 411 + tierName = fmt.Sprintf("%s (via %s)", sponsor.TierName, sponsor.Username) 412 + } 413 + slog.Info("fetchSponsorship: found synced sponsor", 414 + "user", userLogin, 415 + "sponsor_username", sponsor.Username, 416 + "tier_name", tierName, 417 + "monthly_amount_cents", sponsor.MonthlyAmountCents) 418 + 419 + resultJSON, _ := json.Marshal(map[string]any{ 420 + "is_active": true, 421 + "monthly_amount_cents": sponsor.MonthlyAmountCents, 422 + "tier_name": tierName, 423 + }) 424 + return string(resultJSON), nil 425 + } 426 + 395 427 // Check direct user sponsorship 396 428 userSponsorship, err := fetchSponsorshipForEntity(ctx, token, "user", userLogin) 397 429 if err != nil { ··· 514 546 userOrgsWithSponsorship = make(map[string]*sponsorshipInfo) 515 547 } 516 548 517 - // Fetch sponsorship data (checks allowlist, then user, then org sponsorships) 518 - sponsorData, err := fetchSponsorship(r.Context(), token.AccessToken, ghUser.Login, userOrgs, userOrgsWithSponsorship, s.fiftyPlusSponsors) 549 + // Fetch sponsorship data (checks allowlist, synced table, then user, then org sponsorships) 550 + sponsorData, err := fetchSponsorship(r.Context(), s.pool, token.AccessToken, ghUser.Login, userOrgs, userOrgsWithSponsorship, s.fiftyPlusSponsors) 519 551 if err != nil { 520 552 slog.Error("callbackHandler: failed to fetch sponsorship", "err", err) 521 553 // Non-fatal: continue with empty sponsorship data ··· 534 566 SponsorshipData: sponsorData, 535 567 } 536 568 537 - if err := upsertUser(s.db, user); err != nil { 569 + if err := upsertUser(r.Context(), s.pool, user); err != nil { 538 570 slog.Error("callbackHandler: failed to upsert user", "err", err, "github_id", ghUser.ID) 539 571 renderOAuthError(w, "Failed to create user") 540 572 return ··· 616 648 return nil, fmt.Errorf("invalid user id in session") 617 649 } 618 650 slog.Debug("getSessionUser: fetched user from old session format", "user_id", userID) 619 - return getUserByID(s.db, userID) 651 + return getUserByID(r.Context(), s.pool, userID) 620 652 } 621 653 622 654 userID, ok := session.Values["user_id"].(int) ··· 626 658 } 627 659 628 660 slog.Debug("getSessionUser: fetching user from session", "user_id", userID) 629 - return getUserByID(s.db, userID) 661 + return getUserByID(r.Context(), s.pool, userID) 630 662 } 631 663 632 664 // renderOAuthError renders an OAuth error page.
+190
cmd/sponsor-panel/sync_sponsors.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "net/http" 10 + "strings" 11 + "time" 12 + 13 + "github.com/jackc/pgx/v5/pgxpool" 14 + ) 15 + 16 + // graphqlSponsorsResponse represents the GraphQL response for sponsorshipsAsMaintainer. 17 + type graphqlSponsorsResponse struct { 18 + Data struct { 19 + User struct { 20 + SponsorshipsAsMaintainer struct { 21 + PageInfo struct { 22 + HasNextPage bool `json:"hasNextPage"` 23 + EndCursor string `json:"endCursor"` 24 + } `json:"pageInfo"` 25 + Nodes []struct { 26 + SponsorEntity struct { 27 + Typename string `json:"__typename"` 28 + Login string `json:"login"` 29 + } `json:"sponsorEntity"` 30 + Tier struct { 31 + Name string `json:"name"` 32 + MonthlyPriceInCents int `json:"monthlyPriceInCents"` 33 + } `json:"tier"` 34 + IsActive bool `json:"isActive"` 35 + } `json:"nodes"` 36 + } `json:"sponsorshipsAsMaintainer"` 37 + } `json:"user"` 38 + } `json:"data"` 39 + Errors []struct { 40 + Message string `json:"message"` 41 + } `json:"errors"` 42 + } 43 + 44 + // syncSponsors performs a single sync of all sponsors from GitHub. 45 + func syncSponsors(ctx context.Context, pool *pgxpool.Pool, ghToken string) error { 46 + slog.Info("syncSponsors: starting sponsor sync") 47 + 48 + allSponsors := make([]string, 0) 49 + 50 + after := "" 51 + for { 52 + // Build GraphQL query 53 + query := fmt.Sprintf(`query { 54 + user(login: "%s") { 55 + sponsorshipsAsMaintainer(first: 100, after: %s, activeOnly: true) { 56 + pageInfo { hasNextPage, endCursor } 57 + nodes { 58 + sponsorEntity { 59 + __typename 60 + ... on User { login } 61 + ... on Organization { login } 62 + } 63 + tier { name, monthlyPriceInCents } 64 + isActive 65 + } 66 + } 67 + } 68 + }`, *sponsorTarget, formatGraphQLString(after)) 69 + 70 + reqBody := map[string]any{"query": query} 71 + bodyBytes, err := json.Marshal(reqBody) 72 + if err != nil { 73 + slog.Error("syncSponsors: failed to marshal request", "err", err) 74 + return err 75 + } 76 + 77 + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.github.com/graphql", strings.NewReader(string(bodyBytes))) 78 + if err != nil { 79 + slog.Error("syncSponsors: failed to create request", "err", err) 80 + return err 81 + } 82 + 83 + req.Header.Set("Authorization", "Bearer "+ghToken) 84 + req.Header.Set("Content-Type", "application/json") 85 + 86 + resp, err := http.DefaultClient.Do(req) 87 + if err != nil { 88 + slog.Error("syncSponsors: request failed", "err", err) 89 + return err 90 + } 91 + 92 + if resp.StatusCode != http.StatusOK { 93 + body, _ := io.ReadAll(resp.Body) 94 + resp.Body.Close() 95 + slog.Error("syncSponsors: GraphQL API error", "status", resp.StatusCode, "body", string(body)) 96 + return fmt.Errorf("GraphQL API returned status %d: %s", resp.StatusCode, string(body)) 97 + } 98 + 99 + var result graphqlSponsorsResponse 100 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 101 + resp.Body.Close() 102 + slog.Error("syncSponsors: failed to decode response", "err", err) 103 + return err 104 + } 105 + resp.Body.Close() 106 + 107 + // Check for GraphQL errors 108 + if len(result.Errors) > 0 { 109 + slog.Error("syncSponsors: GraphQL errors", "errors", result.Errors) 110 + return fmt.Errorf("GraphQL error: %s", result.Errors[0].Message) 111 + } 112 + 113 + // Process sponsors 114 + for _, node := range result.Data.User.SponsorshipsAsMaintainer.Nodes { 115 + login := node.SponsorEntity.Login 116 + if login == "" { 117 + continue 118 + } 119 + 120 + sponsor := &SponsorUsername{ 121 + Username: login, 122 + EntityType: node.SponsorEntity.Typename, 123 + MonthlyAmountCents: node.Tier.MonthlyPriceInCents, 124 + TierName: node.Tier.Name, 125 + IsActive: true, 126 + } 127 + 128 + // Upsert to database 129 + if err := upsertSponsorUsername(ctx, pool, sponsor); err != nil { 130 + slog.Error("syncSponsors: failed to upsert sponsor", "err", err, "username", login) 131 + continue 132 + } 133 + 134 + allSponsors = append(allSponsors, login) 135 + } 136 + 137 + // Check for next page 138 + if !result.Data.User.SponsorshipsAsMaintainer.PageInfo.HasNextPage { 139 + break 140 + } 141 + after = result.Data.User.SponsorshipsAsMaintainer.PageInfo.EndCursor 142 + } 143 + 144 + // Mark inactive sponsors not in current fetch 145 + inactiveCount, err := markInactiveSponsorsNotIn(ctx, pool, allSponsors) 146 + if err != nil { 147 + slog.Error("syncSponsors: failed to mark inactive sponsors", "err", err) 148 + return err 149 + } 150 + 151 + slog.Info("syncSponsors: sync completed", 152 + "active_sponsors", len(allSponsors), 153 + "marked_inactive", inactiveCount) 154 + 155 + return nil 156 + } 157 + 158 + // formatGraphQLString formats a string for GraphQL (with quotes, or null if empty). 159 + func formatGraphQLString(s string) string { 160 + if s == "" { 161 + return "null" 162 + } 163 + return fmt.Sprintf(`"%s"`, s) 164 + } 165 + 166 + // startSyncLoop runs the sync immediately, then every hour. 167 + func startSyncLoop(ctx context.Context, pool *pgxpool.Pool, ghToken string) { 168 + // Run initial sync immediately 169 + slog.Info("startSyncLoop: running initial sponsor sync") 170 + if err := syncSponsors(ctx, pool, ghToken); err != nil { 171 + slog.Error("startSyncLoop: initial sync failed", "err", err) 172 + } 173 + 174 + // Run every hour 175 + ticker := time.NewTicker(1 * time.Hour) 176 + defer ticker.Stop() 177 + 178 + for { 179 + select { 180 + case <-ctx.Done(): 181 + slog.Info("startSyncLoop: stopping sync loop", "reason", ctx.Err()) 182 + return 183 + case <-ticker.C: 184 + slog.Info("startSyncLoop: running scheduled sponsor sync") 185 + if err := syncSponsors(ctx, pool, ghToken); err != nil { 186 + slog.Error("startSyncLoop: scheduled sync failed", "err", err) 187 + } 188 + } 189 + } 190 + }