From a0f92ce84903a8ee21e7c943654d5ac5e4c2719a Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Mon, 9 Mar 2026 23:59:29 +0200 Subject: [PATCH 01/18] add: username as key normalization for stats cache --- api/internal/cache/stats.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/api/internal/cache/stats.go b/api/internal/cache/stats.go index eb9b926..f8b2041 100644 --- a/api/internal/cache/stats.go +++ b/api/internal/cache/stats.go @@ -1,12 +1,15 @@ package cache import ( + "strings" "time" userstats "github.com/hurtki/github-banners/api/internal/domain/user_stats" "github.com/patrickmn/go-cache" ) +// StatsMemoryCache is in memory cache for storing statistics for username +// It uses lower case of username as cache key, so hurtki and HURTKI are same type StatsMemoryCache struct { cache *cache.Cache } @@ -18,7 +21,9 @@ func NewStatsMemoryCache(defaultTTL time.Duration) *StatsMemoryCache { } func (c *StatsMemoryCache) Get(username string) (*userstats.CachedStats, bool) { - if item, found := c.cache.Get(username); found { + normalizedUsername := strings.ToLower(username) + + if item, found := c.cache.Get(normalizedUsername); found { if stats, ok := item.(*userstats.CachedStats); ok { return stats, true } @@ -27,7 +32,8 @@ func (c *StatsMemoryCache) Get(username string) (*userstats.CachedStats, bool) { } func (c *StatsMemoryCache) Set(username string, entry *userstats.CachedStats, ttl time.Duration) { - c.cache.Set(username, entry, ttl) + normalizedUsername := strings.ToLower(username) + c.cache.Set(normalizedUsername, entry, ttl) } func (c *StatsMemoryCache) Delete(username string) { From 1a82d5d94b9fb273aca8fe05df58828bc07ec8a3 Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Tue, 10 Mar 2026 22:57:24 +0200 Subject: [PATCH 02/18] refactor: statsService code, without logic changes --- api/internal/domain/user_stats/service.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/api/internal/domain/user_stats/service.go b/api/internal/domain/user_stats/service.go index 44f4160..d46a526 100644 --- a/api/internal/domain/user_stats/service.go +++ b/api/internal/domain/user_stats/service.go @@ -26,23 +26,23 @@ func NewUserStatsService(repo GithubUserDataRepository, fetcher UserDataFetcher, func (s *UserStatsService) GetStats(ctx context.Context, username string) (domain.GithubUserStats, error) { cached, found := s.cache.Get(username) if found { - //fresh <10mins + // fresh <10mins age := time.Since(cached.UpdatedAt) if age <= SoftTTL { return cached.Stats, nil } - //stalte >10mins but <24 hours + // state >10mins but <24 hours go func() { - bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + bgCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() _, _ = s.RecalculateAndSync(bgCtx, username) }() return cached.Stats, nil } - //checking database if cache missed - dbData, err := s.repo.GetUserData(context.TODO(), username) + // checking database if cache missed + dbData, err := s.repo.GetUserData(ctx, username) if err == nil { stats := CalculateStats(dbData.Repositories) stats.FetchedAt = dbData.FetchedAt @@ -58,20 +58,20 @@ func (s *UserStatsService) GetStats(ctx context.Context, username string) (domai // fetch api -> save db -> calc stats -> write cache func (s *UserStatsService) RecalculateAndSync(ctx context.Context, username string) (domain.GithubUserStats, error) { - //fetching raw data from github + // fetching raw data from github data, err := s.fetcher.FetchUserData(ctx, username) if err != nil { - return domain.GithubUserStats{}, err + return domain.GithubUserStats{}, fmt.Errorf("can't fetch data for user: %w", err) } stats := CalculateStats(data.Repositories) stats.FetchedAt = data.FetchedAt - //updating database with the new raw data - if err := s.repo.SaveUserData(context.TODO(), *data); err != nil { + // updating database with the new raw data + if err := s.repo.SaveUserData(ctx, *data); err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return domain.GithubUserStats{}, err } - // TODO, we really need to somehow log that we can't save to database or so something with it + // TODO: refactor stats service, to get away from this wrong apporoach } s.cache.Set(username, &CachedStats{ Stats: stats, From 3c6469dd7f0599519c1f464614b9053e44049174 Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Tue, 10 Mar 2026 23:01:49 +0200 Subject: [PATCH 03/18] add: github data users and repositories migration for normalized username --- .../004_add_username_normalized_field.sql | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 api/internal/migrations/004_add_username_normalized_field.sql diff --git a/api/internal/migrations/004_add_username_normalized_field.sql b/api/internal/migrations/004_add_username_normalized_field.sql new file mode 100644 index 0000000..30181e8 --- /dev/null +++ b/api/internal/migrations/004_add_username_normalized_field.sql @@ -0,0 +1,141 @@ +-- +goose Up + +-- deleting foreign key constraint from repositories table +-- to escape conflicts, because now we will change users table +alter table repositories +drop constraint fk_repository_owner; + +-- FIELD username_normalized for github data users table +delete from users a +using users b +where lower(a.username) = lower(b.username) +and a.ctid > b.ctid; + +alter table users +drop constraint users_pkey; + +drop index if exists idx_users_username; + +alter table users +add column username_normalized text; + +update users +set username_normalized = lower(username); + +alter table users +alter column username_normalized set not null; + +alter table users +add constraint users_pkey primary key (username_normalized); + +alter table users +alter column username set not null; + +create index idx_users_username_normalized on users(username_normalized); + +/* +before: +username text primary key + +after: +username_normalized text primary key +username text not null +*/ + +-- normalize repositories table +alter table repositories +add column owner_username_normalized; + +update repositories +set owner_username_normalized = lower(owner_username); + +-- now delete ownwer_username column +-- real username should be stored in users table, repositories table can't contain this +drop index if exists idx_repositories_owner_username; +alter table repositories +drop column owner_username; + +alter table repositories +alter column owner_username_normalized set not null; + +alter table users +add constraint fk_repository_owner + foreign key (owner_username_normalized) + references users(username) on delete cascade; + +CREATE INDEX idx_repositories_owner_username ON repositories(owner_username_normalized); + +/* +before: +owner_username text not null +fk constraint + +after: +owner_username_normalized text not null +fk constraint +*/ + + +-- create schema for better separation of gihub data and our service data +-- cause right now "users" +create schema github_data; + +alter table users +set schema github_data; + +alter table repositories +set schema github_data; + + + + +-- +goose Down +-- drop foreign key from repositories +alter table github_data.repositories +drop constraint fk_respository_owner; + +-- revert owner_username_normalized back to owner_username +alter table github_data.repositories +add column owner_username text; + +update github_data.repositories +set owner_username = owner_username_normalized; + +drop index if exists idx_repositories_owner_username; +create index idx_repositories_owner_username on github_data.repositories(owner_username); + +alter table github_data.repositories +drop column owner_username_normalized; + +-- revert users table primary key +alter table github_data.users +drop constraint users_pkey; + +alter table github_data.users +drop column username_normalized; + +-- recreate original primary key on username +alter table github_data.users +add constraint users_pkey primary key (username); + +alter table github_data.users +alter column username set not null; + +-- recreate index if needed +create index idx_users_username on github_data.users(username); + +-- restore foreign key on repositories.owner_username +alter table github_data.repositories +add constraint fk_repository_owner + foreign key (owner_username) + references github_data.users(username) on delete cascade; + +-- move tables back to original schema (public) +alter table github_data.users +set schema public; + +alter table github_data.repositories +set schema public; + +-- optional: drop schema github_data if empty +-- drop schema github_data; From 3f94dccf164d027f730cf6f7f57682b4f57938d4 Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Tue, 10 Mar 2026 23:28:24 +0200 Subject: [PATCH 04/18] refactor: github data repository to match new db schema and normalizsation --- .../004_add_username_normalized_field.sql | 6 +++--- api/internal/repo/github_user_data/get.go | 11 ++++++----- .../repo/github_user_data/repos_upsert.go | 6 +++--- api/internal/repo/github_user_data/save.go | 17 +++++++++-------- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/api/internal/migrations/004_add_username_normalized_field.sql b/api/internal/migrations/004_add_username_normalized_field.sql index 30181e8..b19f2fd 100644 --- a/api/internal/migrations/004_add_username_normalized_field.sql +++ b/api/internal/migrations/004_add_username_normalized_field.sql @@ -44,7 +44,7 @@ username text not null -- normalize repositories table alter table repositories -add column owner_username_normalized; +add column owner_username_normalized text; update repositories set owner_username_normalized = lower(owner_username); @@ -58,10 +58,10 @@ drop column owner_username; alter table repositories alter column owner_username_normalized set not null; -alter table users +alter table repositories add constraint fk_repository_owner foreign key (owner_username_normalized) - references users(username) on delete cascade; + references users(username_normalized) on delete cascade; CREATE INDEX idx_repositories_owner_username ON repositories(owner_username_normalized); diff --git a/api/internal/repo/github_user_data/get.go b/api/internal/repo/github_user_data/get.go index 3faa143..1c319ed 100644 --- a/api/internal/repo/github_user_data/get.go +++ b/api/internal/repo/github_user_data/get.go @@ -33,8 +33,8 @@ func (r *GithubDataPsgrRepo) GetUserData(ctx context.Context, username string) ( }() row := tx.QueryRowContext(ctx, ` - select username, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at from users - where username = $1; + select username, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at from github_data.users + where username_normalized = lower($1); `, username) data := domain.GithubUserData{} @@ -46,8 +46,8 @@ func (r *GithubDataPsgrRepo) GetUserData(ctx context.Context, username string) ( } rows, err := tx.QueryContext(ctx, ` - select github_id, owner_username, pushed_at, updated_at, language, stars_count, is_fork, forks_count from repositories - where owner_username = $1; + select github_id, pushed_at, updated_at, language, stars_count, is_fork, forks_count from github_data.repositories + where owner_username_normalized = lower($1); `, username) if err != nil { @@ -58,10 +58,11 @@ func (r *GithubDataPsgrRepo) GetUserData(ctx context.Context, username string) ( for rows.Next() { githubRepo := domain.GithubRepository{} - err = rows.Scan(&githubRepo.ID, &githubRepo.OwnerUsername, &githubRepo.PushedAt, &githubRepo.UpdatedAt, &githubRepo.Language, &githubRepo.StarsCount, &githubRepo.Fork, &githubRepo.ForksCount) + err = rows.Scan(&githubRepo.ID, &githubRepo.PushedAt, &githubRepo.UpdatedAt, &githubRepo.Language, &githubRepo.StarsCount, &githubRepo.Fork, &githubRepo.ForksCount) if err != nil { return domain.GithubUserData{}, r.handleError(err, fn+".scanRepositoryRow") } + githubRepo.OwnerUsername = data.Username githubRepos = append(githubRepos, githubRepo) } diff --git a/api/internal/repo/github_user_data/repos_upsert.go b/api/internal/repo/github_user_data/repos_upsert.go index aed497b..66e92c7 100644 --- a/api/internal/repo/github_user_data/repos_upsert.go +++ b/api/internal/repo/github_user_data/repos_upsert.go @@ -31,7 +31,7 @@ func (r *GithubDataPsgrRepo) upsertRepoBatch(ctx context.Context, tx *sql.Tx, ba posParams = append(posParams, fmt.Sprintf("(%s)", strings.Join(tempPosArgs, ", "))) args = append(args, repo.ID, - repo.OwnerUsername, + strings.ToLower(repo.OwnerUsername), repo.PushedAt, repo.UpdatedAt, repo.Language, @@ -43,10 +43,10 @@ func (r *GithubDataPsgrRepo) upsertRepoBatch(ctx context.Context, tx *sql.Tx, ba } query := fmt.Sprintf(` - insert into repositories (github_id, owner_username, pushed_at, updated_at, language, stars_count, is_fork, forks_count) + insert into github_data.repositories (github_id, owner_username_normalized, pushed_at, updated_at, language, stars_count, is_fork, forks_count) values %s on conflict (github_id) do update set - owner_username = excluded.owner_username, + owner_username_normalized = excluded.owner_username_normalized, pushed_at = excluded.pushed_at, updated_at = excluded.updated_at, language = excluded.language, diff --git a/api/internal/repo/github_user_data/save.go b/api/internal/repo/github_user_data/save.go index a3a1184..8ba681b 100644 --- a/api/internal/repo/github_user_data/save.go +++ b/api/internal/repo/github_user_data/save.go @@ -35,9 +35,10 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G }() _, err = tx.ExecContext(ctx, ` - insert into users (username, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9) - on conflict (username) do update set + insert into github_data.users (username, username_normalized, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + on conflict (username_normalized) do update set + username = EXCLUDED.username, name = EXCLUDED.name, company = EXCLUDED.company, location = EXCLUDED.location, @@ -46,7 +47,7 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G followers_count = EXCLUDED.followers_count, following_count = EXCLUDED.following_count, fetched_at = EXCLUDED.fetched_at; - `, userData.Username, userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt) + `, userData.Username, strings.ToLower(userData.Username), userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt) if err != nil { return r.handleError(err, fn+".insertUser") } @@ -54,8 +55,8 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G // if a new data says that there is no repositories, then delete all existing ones if len(userData.Repositories) == 0 { _, err := tx.ExecContext(ctx, ` - delete from repositories - where owner_username = $1; + delete from github_data.repositories + where owner_username_normalized = lower($1); `, userData.Username) if err != nil { @@ -114,8 +115,8 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G } deleteQuery := fmt.Sprintf(` - delete from repositories r - where r.owner_username = $1 + delete from github_data.repositories r + where r.owner_username_normalized = lower($1) and not exists ( select 1 from (values %s) as v(github_id) From 139f3c20149a8ee8cef72ec6436338b2e1ead65e Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Wed, 11 Mar 2026 00:02:34 +0200 Subject: [PATCH 05/18] fix: tests for new github data repo --- .../repo/github_user_data/storage_test.go | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/api/internal/repo/github_user_data/storage_test.go b/api/internal/repo/github_user_data/storage_test.go index adba76d..9dec9bf 100644 --- a/api/internal/repo/github_user_data/storage_test.go +++ b/api/internal/repo/github_user_data/storage_test.go @@ -2,6 +2,7 @@ package github_data_repo import ( "context" + "strings" "testing" "time" @@ -48,9 +49,10 @@ func TestSaveUserDataSucess(t *testing.T) { mock.ExpectBegin() mock.ExpectExec(` - insert into users (username, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9) - on conflict (username) do update set + insert into github_data.users (username, username_normalized, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + on conflict (username_normalized) do update set + username = EXCLUDED.username, name = EXCLUDED.name, company = EXCLUDED.company, location = EXCLUDED.location, @@ -59,24 +61,24 @@ func TestSaveUserDataSucess(t *testing.T) { followers_count = EXCLUDED.followers_count, following_count = EXCLUDED.following_count, fetched_at = EXCLUDED.fetched_at; - `).WithArgs(userData.Username, userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt).WillReturnResult(sqlmock.NewResult(1, 1)) + `).WithArgs(userData.Username, strings.ToLower(userData.Username), userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(` - insert into repositories (github_id, owner_username, pushed_at, updated_at, language, stars_count, is_fork, forks_count) + insert into github_data.repositories (github_id, owner_username_normalized, pushed_at, updated_at, language, stars_count, is_fork, forks_count) values ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $14, $15, $16) on conflict (github_id) do update set - owner_username = excluded.owner_username, + owner_username_normalized = excluded.owner_username_normalized, pushed_at = excluded.pushed_at, updated_at = excluded.updated_at, language = excluded.language, stars_count = excluded.stars_count, is_fork = excluded.is_fork, forks_count = excluded.forks_count; - `).WithArgs(githubRepo1.ID, githubRepo1.OwnerUsername, githubRepo1.PushedAt, githubRepo1.UpdatedAt, githubRepo1.Language, githubRepo1.StarsCount, githubRepo1.Fork, githubRepo1.ForksCount, githubRepo2.ID, githubRepo2.OwnerUsername, githubRepo2.PushedAt, githubRepo2.UpdatedAt, githubRepo2.Language, githubRepo2.StarsCount, githubRepo2.Fork, githubRepo2.ForksCount).WillReturnResult(sqlmock.NewResult(1, 1)) + `).WithArgs(githubRepo1.ID, strings.ToLower(githubRepo1.OwnerUsername), githubRepo1.PushedAt, githubRepo1.UpdatedAt, githubRepo1.Language, githubRepo1.StarsCount, githubRepo1.Fork, githubRepo1.ForksCount, githubRepo2.ID, githubRepo2.OwnerUsername, githubRepo2.PushedAt, githubRepo2.UpdatedAt, githubRepo2.Language, githubRepo2.StarsCount, githubRepo2.Fork, githubRepo2.ForksCount).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(` - delete from repositories r - where r.owner_username = $1 + delete from github_data.repositories r + where r.owner_username_normalized = lower($1) and not exists ( select 1 from (values ($2::bigint), ($3::bigint)) as v(github_id) @@ -101,9 +103,10 @@ func TestSaveUserDataSucessNoRepos(t *testing.T) { mock.ExpectBegin() mock.ExpectExec(` - insert into users (username, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9) - on conflict (username) do update set + insert into github_data.users (username, username_normalized, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + on conflict (username_normalized) do update set + username = EXCLUDED.username, name = EXCLUDED.name, company = EXCLUDED.company, location = EXCLUDED.location, @@ -112,11 +115,11 @@ func TestSaveUserDataSucessNoRepos(t *testing.T) { followers_count = EXCLUDED.followers_count, following_count = EXCLUDED.following_count, fetched_at = EXCLUDED.fetched_at; - `).WithArgs(userData.Username, userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt).WillReturnResult(sqlmock.NewResult(1, 1)) + `).WithArgs(userData.Username, strings.ToLower(userData.Username), userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(` - delete from repositories - where owner_username = $1; + delete from github_data.repositories + where owner_username_normalized = lower($1); `).WithArgs(userData.Username).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() @@ -134,7 +137,7 @@ func TestGetAllUsernamesSuccess(t *testing.T) { usernameRows.AddRow(un) } mock.ExpectQuery(` - select username from users; + select username from github_data.users; `).WillReturnRows(usernameRows) resUsernames, err := repo.GetAllUsernames(context.TODO()) @@ -147,7 +150,7 @@ func TestGetAllUsernamesNoUsernames(t *testing.T) { mock, repo := getMockAndRepo(t) mock.ExpectQuery(` - select username from users; + select username from github_data.users; `).WillReturnRows(sqlmock.NewRows([]string{"username"})) resUsernames, err := repo.GetAllUsernames(context.TODO()) @@ -159,16 +162,19 @@ func TestGetAllUsernamesNoUsernames(t *testing.T) { func TestGetUserDataSuccess(t *testing.T) { mock, repo := getMockAndRepo(t) - userData := domain.GithubUserData{Username: "Olivia"} + userData := domain.GithubUserData{Username: "OliVia"} repo1 := domain.GithubRepository{ID: 123, OwnerUsername: userData.Username} repo2 := domain.GithubRepository{ID: 3454, OwnerUsername: userData.Username} userData.Repositories = []domain.GithubRepository{repo1, repo2} + userColumns := []string{"username", "name", "company", "location", "bio", "public_repos_count", "followers_count", "following_count", "fetched_at"} - githubRepoColumns := []string{"github_id", "owner_username", "pushed_at", "updated_at", "language", "stars_count", "is_fork", "forks_count"} + + githubRepoColumns := []string{"github_id", "pushed_at", "updated_at", "language", "stars_count", "is_fork", "forks_count"} githubReposRows := sqlmock.NewRows(githubRepoColumns) + for _, githubRepo := range userData.Repositories { - githubReposRows.AddRow(githubRepo.ID, githubRepo.OwnerUsername, githubRepo.PushedAt, githubRepo.UpdatedAt, githubRepo.Language, githubRepo.StarsCount, githubRepo.Fork, githubRepo.ForksCount) + githubReposRows.AddRow(githubRepo.ID, githubRepo.PushedAt, githubRepo.UpdatedAt, githubRepo.Language, githubRepo.StarsCount, githubRepo.Fork, githubRepo.ForksCount) } userRows := sqlmock.NewRows(userColumns) @@ -176,13 +182,13 @@ func TestGetUserDataSuccess(t *testing.T) { mock.ExpectBegin() mock.ExpectQuery(` - select username, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at from users - where username = $1; + select username, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at from github_data.users + where username_normalized = lower($1); `).WithArgs(userData.Username).WillReturnRows(userRows) mock.ExpectQuery(` - select github_id, owner_username, pushed_at, updated_at, language, stars_count, is_fork, forks_count from repositories - where owner_username = $1; + select github_id, pushed_at, updated_at, language, stars_count, is_fork, forks_count from github_data.repositories + where owner_username_normalized = lower($1); `).WithArgs(userData.Username).WillReturnRows(githubReposRows) mock.ExpectCommit() From 9356ff7367e5d6bf5063bec700365573598a1ad3 Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Wed, 11 Mar 2026 00:02:55 +0200 Subject: [PATCH 06/18] refactor: get all usernames method to match new db schema --- api/internal/repo/github_user_data/usernames.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/internal/repo/github_user_data/usernames.go b/api/internal/repo/github_user_data/usernames.go index e6ea732..2257c56 100644 --- a/api/internal/repo/github_user_data/usernames.go +++ b/api/internal/repo/github_user_data/usernames.go @@ -6,7 +6,7 @@ func (r *GithubDataPsgrRepo) GetAllUsernames(ctx context.Context) ([]string, err fn := "internal.repo.github_user_data.GithubDataPsgrRepo.GetAllUsernames" rows, err := r.db.QueryContext(ctx, ` - select username from users; + select username from github_data.users; `) if err != nil { From 3fefa2e24e45194370c716460a9e633eb0eb0127 Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Thu, 12 Mar 2026 20:46:41 +0200 Subject: [PATCH 07/18] add: banners table migration with username normalized field --- ..._add_username_normalized_banners_table.sql | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 api/internal/migrations/005_add_username_normalized_banners_table.sql diff --git a/api/internal/migrations/005_add_username_normalized_banners_table.sql b/api/internal/migrations/005_add_username_normalized_banners_table.sql new file mode 100644 index 0000000..2613ea0 --- /dev/null +++ b/api/internal/migrations/005_add_username_normalized_banners_table.sql @@ -0,0 +1,48 @@ +-- +goose Up +alter table banners +drop constraint banners_github_username_banner_type_key; + +alter table banners +add column github_username_normalized text; + +update banners +set github_username_normalized = lower(github_username); + +alter table banners +alter column github_username_normalized set not null; + +alter table banners +drop column github_username; + +delete from banners a +using banners b +where a.github_username_normalized = b.github_username_normalized +and a.ctid > b.ctid; + +alter table banners +add constraint banners_github_username_normalized_banner_type_key unique (github_username_normalized, banner_type); + +create index idx_banners_username_normalized +on banners (github_username_normalized); + +-- +goose Down +alter table banners +drop constraint banners_github_username_normalized_banner_type_key; + +drop index if exists idx_banners_username_normalized; + +alter table banners +add column github_username text; + +update banners +set github_username = github_username_normalized; + +alter table banners +alter column github_username set not null; + +alter table banners +drop column github_username_normalized; + +alter table banners +add constraint banners_github_username_banner_type_key +unique (github_username, banner_type); From 1718c95079aeaa0f2c3b9c6918fbbf31809a77e2 Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Thu, 12 Mar 2026 20:47:18 +0200 Subject: [PATCH 08/18] refactor: banners repo logic to match new table scheme with normalized username --- api/internal/repo/banners/postgres_queries.go | 12 ++++++------ api/main.go | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/internal/repo/banners/postgres_queries.go b/api/internal/repo/banners/postgres_queries.go index 35134b8..44ae1bb 100644 --- a/api/internal/repo/banners/postgres_queries.go +++ b/api/internal/repo/banners/postgres_queries.go @@ -12,7 +12,7 @@ import ( func (r *PostgresRepo) GetActiveBanners(ctx context.Context) ([]domain.LTBannerMetadata, error) { fn := "internal.repo.banners.PostgresRepo.GetActiveBanners" - const q = `select github_username, banner_type, storage_path from banners where is_active = true` + const q = `select github_username_normalized, banner_type, storage_path from banners where is_active = true` rows, err := r.db.QueryContext(ctx, q) if err != nil { r.logger.Error("unexpected error when querying banners", "source", fn, "err", err) @@ -67,9 +67,9 @@ func (r *PostgresRepo) SaveBanner(ctx context.Context, b domain.LTBannerMetadata } const q = ` - insert into banners (github_username, banner_type, storage_path, is_active) - values ($1, $2, $3, $4) - on conflict (github_username, banner_type) do update set + insert into banners (github_username_normalized, banner_type, storage_path, is_active) + values (lower($1), $2, $3, $4) + on conflict (github_username_normalized, banner_type) do update set is_active = EXCLUDED.is_active, storage_path = EXCLUDED.storage_path; ` @@ -95,7 +95,7 @@ func (r *PostgresRepo) DeactivateBanner(ctx context.Context, githubUsername stri const q = ` update banners set is_active = false - where github_username = $1 and banner_type = $2 and is_active = true` + where github_username_normalized = lower($1) and banner_type = $2 and is_active = true` res, err := r.db.ExecContext(ctx, q, githubUsername, domain.BannerTypesBackward[bannerType]) if err != nil { @@ -119,7 +119,7 @@ func (r *PostgresRepo) GetBanner(ctx context.Context, githubUsername string, ban fn := "internal.repo.banners.PostgresRepo.GetBanner" const q = ` select storage_path, is_active from banners - where github_username = $1 and banner_type = $2;` + where github_username_normalized = lower($1) and banner_type = $2;` meta := domain.LTBannerMetadata{Username: githubUsername, BannerType: bannerType} err := r.db.QueryRowContext(ctx, q, githubUsername, domain.BannerTypesBackward[bannerType]).Scan(&meta.UrlPath, &meta.Active) diff --git a/api/main.go b/api/main.go index cad8b84..6ad2efe 100644 --- a/api/main.go +++ b/api/main.go @@ -67,10 +67,10 @@ func main() { logger.Error("failed to run migrations", "err", err.Error()) os.Exit(1) } - repo := github_data_repo.NewGithubDataPsgrRepo(db, logger) + githubDataRepo := github_data_repo.NewGithubDataPsgrRepo(db, logger) // Create stats service (domain service with cache) - statsService := userstats.NewUserStatsService(repo, githubFetcher, statsCache) + statsService := userstats.NewUserStatsService(githubDataRepo, githubFetcher, statsCache) router := chi.NewRouter() From 75e4b428eadc371fc99f6e637ac29b15c91f58cf Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Thu, 12 Mar 2026 20:56:46 +0200 Subject: [PATCH 09/18] refactor: github data repo to use lower function only in sql ( not in go code ) --- api/internal/repo/github_user_data/repos_upsert.go | 12 ++++++++++-- api/internal/repo/github_user_data/save.go | 4 ++-- api/internal/repo/github_user_data/storage_test.go | 10 +++++----- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/api/internal/repo/github_user_data/repos_upsert.go b/api/internal/repo/github_user_data/repos_upsert.go index 66e92c7..45d164a 100644 --- a/api/internal/repo/github_user_data/repos_upsert.go +++ b/api/internal/repo/github_user_data/repos_upsert.go @@ -26,12 +26,20 @@ func (r *GithubDataPsgrRepo) upsertRepoBatch(ctx context.Context, tx *sql.Tx, ba for _, repo := range batch { tempPosArgs := []string{} for j := i; j < i+8; j++ { - tempPosArgs = append(tempPosArgs, fmt.Sprintf("$%d", j)) + // don't forget to use lower for OwnerUsername for normalization + // 1 (2) 3 4 5 6 7 8 + // 9 (10) 11 12 13 14 + // ... + if j%8 == 2 { + tempPosArgs = append(tempPosArgs, fmt.Sprintf("lower($%d)", j)) + } else { + tempPosArgs = append(tempPosArgs, fmt.Sprintf("$%d", j)) + } } posParams = append(posParams, fmt.Sprintf("(%s)", strings.Join(tempPosArgs, ", "))) args = append(args, repo.ID, - strings.ToLower(repo.OwnerUsername), + repo.OwnerUsername, repo.PushedAt, repo.UpdatedAt, repo.Language, diff --git a/api/internal/repo/github_user_data/save.go b/api/internal/repo/github_user_data/save.go index 8ba681b..d63c273 100644 --- a/api/internal/repo/github_user_data/save.go +++ b/api/internal/repo/github_user_data/save.go @@ -36,7 +36,7 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G _, err = tx.ExecContext(ctx, ` insert into github_data.users (username, username_normalized, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + values ($1, lower($2), $3, $4, $5, $6, $7, $8, $9, $10) on conflict (username_normalized) do update set username = EXCLUDED.username, name = EXCLUDED.name, @@ -47,7 +47,7 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G followers_count = EXCLUDED.followers_count, following_count = EXCLUDED.following_count, fetched_at = EXCLUDED.fetched_at; - `, userData.Username, strings.ToLower(userData.Username), userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt) + `, userData.Username, userData.Username, userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt) if err != nil { return r.handleError(err, fn+".insertUser") } diff --git a/api/internal/repo/github_user_data/storage_test.go b/api/internal/repo/github_user_data/storage_test.go index 9dec9bf..7a1a233 100644 --- a/api/internal/repo/github_user_data/storage_test.go +++ b/api/internal/repo/github_user_data/storage_test.go @@ -50,7 +50,7 @@ func TestSaveUserDataSucess(t *testing.T) { mock.ExpectExec(` insert into github_data.users (username, username_normalized, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + values ($1, lower($2), $3, $4, $5, $6, $7, $8, $9, $10) on conflict (username_normalized) do update set username = EXCLUDED.username, name = EXCLUDED.name, @@ -61,11 +61,11 @@ func TestSaveUserDataSucess(t *testing.T) { followers_count = EXCLUDED.followers_count, following_count = EXCLUDED.following_count, fetched_at = EXCLUDED.fetched_at; - `).WithArgs(userData.Username, strings.ToLower(userData.Username), userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt).WillReturnResult(sqlmock.NewResult(1, 1)) + `).WithArgs(userData.Username, userData.Username, userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(` insert into github_data.repositories (github_id, owner_username_normalized, pushed_at, updated_at, language, stars_count, is_fork, forks_count) - values ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $14, $15, $16) + values ($1, lower($2), $3, $4, $5, $6, $7, $8), ($9, lower($10), $11, $12, $13, $14, $15, $16) on conflict (github_id) do update set owner_username_normalized = excluded.owner_username_normalized, pushed_at = excluded.pushed_at, @@ -74,7 +74,7 @@ func TestSaveUserDataSucess(t *testing.T) { stars_count = excluded.stars_count, is_fork = excluded.is_fork, forks_count = excluded.forks_count; - `).WithArgs(githubRepo1.ID, strings.ToLower(githubRepo1.OwnerUsername), githubRepo1.PushedAt, githubRepo1.UpdatedAt, githubRepo1.Language, githubRepo1.StarsCount, githubRepo1.Fork, githubRepo1.ForksCount, githubRepo2.ID, githubRepo2.OwnerUsername, githubRepo2.PushedAt, githubRepo2.UpdatedAt, githubRepo2.Language, githubRepo2.StarsCount, githubRepo2.Fork, githubRepo2.ForksCount).WillReturnResult(sqlmock.NewResult(1, 1)) + `).WithArgs(githubRepo1.ID, githubRepo1.OwnerUsername, githubRepo1.PushedAt, githubRepo1.UpdatedAt, githubRepo1.Language, githubRepo1.StarsCount, githubRepo1.Fork, githubRepo1.ForksCount, githubRepo2.ID, githubRepo2.OwnerUsername, githubRepo2.PushedAt, githubRepo2.UpdatedAt, githubRepo2.Language, githubRepo2.StarsCount, githubRepo2.Fork, githubRepo2.ForksCount).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(` delete from github_data.repositories r @@ -104,7 +104,7 @@ func TestSaveUserDataSucessNoRepos(t *testing.T) { mock.ExpectExec(` insert into github_data.users (username, username_normalized, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + values ($1, lower($2), $3, $4, $5, $6, $7, $8, $9, $10) on conflict (username_normalized) do update set username = EXCLUDED.username, name = EXCLUDED.name, From 3b812e86cdb0b8c7d323163694f1db7470709c5d Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Thu, 12 Mar 2026 21:06:29 +0200 Subject: [PATCH 10/18] refactor: tests script with coverage --- run_tests.sh | 74 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/run_tests.sh b/run_tests.sh index 9a6ab22..0d65e1c 100644 --- a/run_tests.sh +++ b/run_tests.sh @@ -1,7 +1,69 @@ #!/bin/sh -cd api -go test -count=1 ./... | grep -Ev "no test files|skipped" -cd ../renderer -go test -count=1 ./... | grep -Ev "no test files|skipped" -cd ../storage -go test -count=1 ./... | grep -Ev "no test files|skipped" + +ROOT_DIR=$(pwd) +RESULTS="/tmp/cov_$$.txt" +rm -f "$RESULTS" + +run_service() { + service=$1 + dir="${ROOT_DIR}/${service}" + printf "\n%s\n" "$service" + + if [ ! -d "$dir" ]; then + printf " not found\n" + echo "$service 0.0" >>"$RESULTS" + return + fi + + cd "$dir" + total=0 + count=0 + tmp="/tmp/gt_$$.txt" + + go test -count=1 -cover ./... 2>&1 | + grep -Ev 'no test files|skipped' >"$tmp" + + while IFS= read -r line; do + [ -z "$line" ] && continue + status=$(printf '%s' "$line" | awk '{print $1}') + pkg=$(printf '%s' "$line" | awk '{print $2}' | awk -F'/' '{print $NF}') + elapsed=$(printf '%s' "$line" | grep -o '[0-9]*\.[0-9]*s' | head -1) + cov=$(printf '%s' "$line" | grep -o 'coverage: [0-9]*\.[0-9]*' | awk '{print $2}') + [ -z "$elapsed" ] && elapsed="-" + [ -z "$cov" ] && cov="0.0" + + if [ "$status" = "ok" ]; then + printf " ok %-30s %6s %s%%\n" "$pkg" "$elapsed" "$cov" + total=$(awk -v a="$total" -v b="$cov" 'BEGIN{printf "%.4f",a+b}') + count=$((count + 1)) + elif [ "$status" = "FAIL" ]; then + printf " FAIL %-30s\n" "$pkg" + count=$((count + 1)) + fi + done <"$tmp" + rm -f "$tmp" + + avg="0.0" + [ "$count" -gt 0 ] && avg=$(awk -v t="$total" -v c="$count" 'BEGIN{printf "%.1f",t/c}') + printf " avg coverage: %s%%\n" "$avg" + + echo "$service $avg" >>"$RESULTS" + cd "$ROOT_DIR" +} + +run_service "api" +run_service "renderer" +run_service "storage" + +printf "\ncoverage summary\n" + +while IFS= read -r line; do + svc=$(printf '%s' "$line" | awk '{print $1}') + val=$(printf '%s' "$line" | awk '{print $2}') + printf " %-10s %s%%\n" "$svc" "$val" +done <"$RESULTS" + +overall=$(awk '{s+=$2; c++} END{printf "%.1f",s/c}' "$RESULTS") +printf " %-10s %s%%\n" "total" "$overall" + +rm -f "$RESULTS" From d724af58f953d5af992b073dc6f10cef746717cf Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Sat, 14 Mar 2026 02:28:37 +0200 Subject: [PATCH 11/18] update: api/architecture docs according to new api + add: fixed api in handlers instead of err.Error() --- api/docs/api.yaml | 246 ++++---------------- api/docs/architecture.md | 274 +++++----------------- api/docs/image.png | Bin 47596 -> 0 bytes api/docs/manual.md | 385 ------------------------------- api/internal/handlers/banners.go | 17 +- 5 files changed, 108 insertions(+), 814 deletions(-) delete mode 100644 api/docs/image.png delete mode 100644 api/docs/manual.md diff --git a/api/docs/api.yaml b/api/docs/api.yaml index e090100..2eea8f0 100644 --- a/api/docs/api.yaml +++ b/api/docs/api.yaml @@ -3,26 +3,22 @@ info: title: GitHub Banners API description: API for generating GitHub user statistics banners as SVG images version: 1.0.0 - servers: - url: http://localhost:8080 description: Local development server - paths: - /banners/preview/: + /banners/preview: get: summary: Get banner preview for a GitHub user description: | Generates and returns an SVG banner with GitHub user statistics. - + The banner includes: - Total repositories count - Original vs forked repositories breakdown - Total stars received - Total forks - Top programming languages used - - Data is cached for performance (see caching strategy in docs). operationId: getBannerPreview parameters: - name: username @@ -38,8 +34,8 @@ paths: description: Banner type schema: type: string - enum: [wide] - example: wide + enum: [dark, default] + example: dark responses: '200': description: Successfully generated banner @@ -48,7 +44,7 @@ paths: schema: type: string format: binary - example: ... + example: '...' '400': description: Invalid request parameters content: @@ -68,27 +64,28 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - example: - error: user doesn't exist + examples: + user_doesnt_exist: + value: + error: user doesn't exist '500': description: Internal server error or service unavailable content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - example: - error: can't get preview - - /banners/: + examples: + cant_get_preview: + value: + error: can't get preview + /banners: post: summary: Create a persistent banner description: | Creates a long-term stored banner for a GitHub user. - - **Note**: This endpoint is not yet implemented (returns 501). - Future implementation will: + - Generate and store banner in storage service - - Return a persistent URL for embedding + - Return a relative URL for embedding - Support automatic refresh of stored banners operationId: createBanner requestBody: @@ -98,13 +95,39 @@ paths: schema: $ref: '#/components/schemas/CreateBannerRequest' responses: - '501': - description: Not implemented + '400': + description: Invalid request content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - + examples: + invalid_json: + value: + error: invalid json + invalid_banner_type: + value: + error: invalid banner type + '404': + description: Requested user doesn't exist + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + user_doesnt_exist: + value: + error: user doesn't exist + '500': + description: Server can't create banner due to internal issues + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + cant_create_banner: + value: + error: can't create banner components: schemas: ErrorResponse: @@ -115,7 +138,6 @@ components: error: type: string description: Error message describing what went wrong - CreateBannerRequest: type: object required: @@ -128,181 +150,7 @@ components: example: torvalds banner_type: type: string - enum: [wide] + enum: [dark, default] description: Type of banner to create - example: wide + example: dark - BannerPreviewRequest: - type: object - description: Request sent to the renderer service - properties: - username: - type: string - example: torvalds - banner_type: - type: string - example: wide - total_repos: - type: integer - description: Total number of repositories - example: 42 - original_repos: - type: integer - description: Number of original (non-forked) repositories - example: 35 - forked_repos: - type: integer - description: Number of forked repositories - example: 7 - total_stars: - type: integer - description: Total stars across all original repositories - example: 150000 - total_forks: - type: integer - description: Total forks across all original repositories - example: 45000 - languages: - type: object - description: Language name to repository count mapping - additionalProperties: - type: integer - example: - Go: 15 - Python: 10 - TypeScript: 8 - - GithubUserStats: - type: object - description: Calculated statistics for a GitHub user - properties: - totalRepos: - type: integer - description: Total number of repositories - example: 42 - originalRepos: - type: integer - description: Number of original (non-forked) repositories - example: 35 - forkedRepos: - type: integer - description: Number of forked repositories - example: 7 - totalStars: - type: integer - description: Total stars received on original repositories - example: 150000 - totalForks: - type: integer - description: Total forks of original repositories - example: 45000 - languages: - type: object - description: Programming language to repository count - additionalProperties: - type: integer - example: - Go: 15 - Python: 10 - - GithubBannerInfoEvent: - type: object - description: Kafka event for banner info (future use) - properties: - event_type: - type: string - example: github_banner_info_ready - event_version: - type: integer - example: 1 - produced_at: - type: string - format: date-time - payload: - $ref: '#/components/schemas/BannerEventPayload' - - BannerEventPayload: - type: object - description: Payload for banner Kafka events - properties: - username: - type: string - example: torvalds - banner_type: - type: string - example: wide - storage_path: - type: string - description: Path where banner is stored - example: /banners/torvalds/wide.svg - stats: - $ref: '#/components/schemas/GithubUserStats' - fetched_at: - type: string - format: date-time - description: When the data was fetched from GitHub - - GithubRepository: - type: object - description: Repository data stored in database - properties: - id: - type: integer - format: int64 - description: GitHub repository ID - owner_username: - type: string - description: Repository owner's GitHub username - pushed_at: - type: string - format: date-time - nullable: true - updated_at: - type: string - format: date-time - nullable: true - language: - type: string - nullable: true - description: Primary programming language - stars_count: - type: integer - description: Number of stars - forks_count: - type: integer - description: Number of forks - is_fork: - type: boolean - description: Whether this is a forked repository - - GithubUserData: - type: object - description: Complete GitHub user data stored in database - properties: - username: - type: string - name: - type: string - nullable: true - company: - type: string - nullable: true - location: - type: string - nullable: true - bio: - type: string - nullable: true - public_repos: - type: integer - followers: - type: integer - following: - type: integer - repositories: - type: array - items: - $ref: '#/components/schemas/GithubRepository' - fetched_at: - type: string - format: date-time \ No newline at end of file diff --git a/api/docs/architecture.md b/api/docs/architecture.md index fe15357..80f1405 100644 --- a/api/docs/architecture.md +++ b/api/docs/architecture.md @@ -4,212 +4,23 @@ ![alt text](image.png) -## Directory Structure +### 1. Clean Architecture -``` -internal/ -├── app/ -│ └── user_stats/ -│ └── worker.go # Background worker for scheduled stats updates -├── cache/ -│ ├── stats.go # In-memory cache for user stats -│ ├── preview.go # In-memory cache for rendered banners -│ └── banner_info_hash.go # Hash utility for banner cache keys -├── config/ -│ ├── config.go # Main application config (env variables) -│ ├── kafka.go # Kafka configuration (future use) -│ └── psgr.go # PostgreSQL configuration -├── domain/ -│ ├── banner.go # Banner types, BannerInfo, LTBannerInfo, Banner structs -│ ├── types.go # GithubRepository, GithubUserData, GithubUserStats models -│ ├── errors.go # Domain errors (ErrNotFound, ErrUnavailable, ConflictError) -│ ├── preview/ -│ │ ├── usecase.go # PreviewUsecase - orchestrates stats + rendering -│ │ ├── service.go # PreviewService - caches renderer results with singleflight -│ │ └── errors.go # Preview-specific errors -│ └── user_stats/ -│ ├── service.go # UserStatsService - core business logic with cache strategy -│ ├── calculator.go # CalculateStats - aggregates repository statistics -│ ├── models.go # CachedStats, WorkerConfig structs -│ ├── cache.go # Cache interface definition -│ └── interface.go # Repository and fetcher interfaces -├── handlers/ -│ ├── banners.go # HTTP handlers for /banners/* endpoints -│ ├── dto.go # Future: DTOs for Create endpoint -│ └── error_response.go # Error response helper -├── infrastructure/ -│ ├── db/ -│ │ └── connection.go # PostgreSQL connection setup -│ ├── github/ -│ │ ├── fetcher.go # GitHub API client with rate limit handling -│ │ └── clients_pool.go # Multi-token client pool for rate limit distribution -│ ├── kafka/ -│ │ ├── producer.go # Kafka event producer (future use) -│ │ └── dto.go # Kafka event DTOs -│ ├── renderer/ -│ │ ├── renderer.go # Renderer HTTP client for banner rendering -│ │ ├── dto.go # Renderer request/response DTOs -│ │ └── http/ -│ │ └── client.go # HTTP client factory for renderer -│ ├── httpauth/ -│ │ ├── hmac_signer.go # HMAC request signing for inter-service auth -│ │ └── round_tripper.go # Auth HTTP round tripper -│ ├── server/ -│ │ └── server.go # HTTP server setup with CORS -│ └── storage/ -│ ├── client.go # Storage service HTTP client -│ └── dto.go # Storage request/response DTOs -├── logger/ -│ └── logger.go # Structured logging (slog-based) -├── migrations/ -│ ├── migrations.go # Goose migration runner (embedded SQL files) -│ ├── 001_create_users_table.sql -│ ├── 002_create_repositories_table.sql -│ └── 003_create_banners_table.sql -└── repo/ - ├── banners/ # Banner repository (future use) - │ ├── interface.go - │ ├── postgres.go - │ ├── postgres_mapper.go - │ └── postgres_queries.go - └── github_user_data/ - ├── storage.go # GithubDataPsgrRepo struct - ├── get.go # GetUserData - fetch user from DB - ├── save.go # SaveUserData - persist user to DB - ├── repos_upsert.go # Batch upsert repositories - ├── usernames.go # GetAllUsernames - for worker refresh - ├── error_mapping.go # PostgreSQL error mapping - └── storage_test.go # Repository tests -``` - -## Data Flow - -### Banner Preview Request - -``` -1. Client ──▶ GET /banners/preview/?username=X&type=wide - -2. Handler (BannersHandler.Preview) ──▶ PreviewUsecase.GetPreview(username, type) - -3. PreviewUsecase flow: - ├─▶ Validate banner type - ├─▶ StatsService.GetStats(username) - │ └─▶ See StatsService flow below - └─▶ PreviewProvider.GetPreview(bannerInfo) - └─▶ See PreviewService flow below - -4. StatsService.GetStats flow: - ├─▶ Check in-memory cache - │ ├─▶ If fresh (<10min): return cached stats - │ └─▶ If stale (>10min but <24h): return cached, trigger async refresh - ├─▶ If cache miss: Check database (repo.GetUserData) - │ └─▶ If found: cache it, return stats - └─▶ If db miss: Fetch from GitHub API (fetcher.FetchUserData) - ├─▶ Save to database (repo.SaveUserData) - ├─▶ Calculate stats (CalculateStats) - ├─▶ Cache results - └─▶ Return stats - -5. PreviewService.GetPreview flow: - ├─▶ Generate hash key from BannerInfo (excludes FetchedAt) - ├─▶ Check in-memory cache by hash - │ └─▶ If found: return cached banner - └─▶ If miss: Render via singleflight (dedupe concurrent requests) - ├─▶ Call renderer.RenderPreview(bannerInfo) - │ └─▶ HTTP POST to renderer service (with HMAC auth) - ├─▶ Cache result by hash - └─▶ Return banner (SVG bytes) - -6. Handler ──▶ Response (image/svg+xml) -``` - -### Background Worker Flow - -``` -StatsWorker.Start (runs every hour by default) - │ - ▼ -RefreshAll(ctx, config) - │ - ├─▶ Get all usernames from database (repo.GetAllUsernames) - │ - └─▶ Worker pool (configurable concurrency): - └─▶ RecalculateAndSync(username) - ├─▶ Fetch fresh data from GitHub API - ├─▶ Save to database - ├─▶ Calculate stats - └─▶ Update cache -``` - -## Database Schema - -### Users Table - -```sql -CREATE TABLE IF NOT EXISTS users ( - username TEXT PRIMARY KEY, - name TEXT, - company TEXT, - location TEXT, - bio TEXT, - public_repos_count INT NOT NULL, - followers_count INT, - following_count INT, - fetched_at TIMESTAMP NOT NULL -); - -CREATE INDEX idx_users_username ON users(username); -``` - -### Repositories Table - -```sql -CREATE TABLE IF NOT EXISTS repositories ( - github_id BIGINT PRIMARY KEY, - owner_username TEXT NOT NULL, - pushed_at TIMESTAMP, - updated_at TIMESTAMP, - language TEXT, - stars_count INT NOT NULL, - forks_count INT NOT NULL, - is_fork BOOLEAN NOT NULL, - CONSTRAINT fk_repository_owner - FOREIGN KEY (owner_username) - REFERENCES users(username) ON DELETE CASCADE -); - -CREATE INDEX idx_repositories_owner_username ON repositories(owner_username); -``` - -### Banners Table - -```sql -CREATE TABLE IF NOT EXISTS banners ( - github_username TEXT PRIMARY KEY, - banner_type TEXT NOT NULL, - storage_path TEXT NOT NULL, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_banners_github_username ON banners(github_username); -``` - -## Key Design Patterns - -### 1. Clean Architecture / Hexagonal Architecture - -- **Domain Layer**: Pure business logic (`domain/`, `domain/user_stats/`, `domain/preview/`) -- **Application Layer**: Use cases and app services (`app/`) -- **Infrastructure Layer**: External services (`infrastructure/`) -- **Interface Layer**: HTTP handlers (`handlers/`) +- **Domain Layer**: Pure business logic usecases and services (`domain/`, `domain/user_stats/`, `domain/preview/`) + Define logic +- **Transport Layer**: HTTP handlers (`handlers/`) + Define API +- **Application Layer** Workers/shedulers (`app/`) + Define sheduling +- **Infrastructure Layer**: External services domain uses through interfaces (`infrastructure/`) + "Helpers" for domain logic, doesn't contain any buisness logic of the service ### 2. Repository Pattern -- `GithubUserDataRepository` interface for data persistence -- PostgreSQL implementation in `repo/github_user_data/` -- Abstracts database operations from domain logic +- github user data repository ( PostgreSQL ) +- banners repository ( PostgreSQL ) + +These repositories use unified `internal/repo/errors.go` to let domain understand errors ### 3. Cache-Aside Pattern with TTL Strategy @@ -219,10 +30,10 @@ CREATE INDEX idx_banners_github_username ON banners(github_username); - Hard TTL (24 hours): Data considered stale after soft TTL, returned but async refreshed - **Preview cache**: Uses hash of BannerInfo (excluding FetchedAt) as key -### 4. Worker Pattern +### 4. Workers -- Background scheduled tasks via `StatsWorker` -- Concurrent processing with configurable batch size and worker count +- Background scheduled tasks via `StatsWorker` and `BannersWorker` +- Concurrent processing with configurable concurrency rate ( gorutines count for every update ) - Results/errors collected via channels ### 5. Singleflight Pattern @@ -236,24 +47,41 @@ CREATE INDEX idx_banners_github_username ON banners(github_username); - Automatic token rotation based on rate limit status - Prevents single-token rate limit exhaustion -## External Dependencies +### 7. Errors flow + +- Errors come from domain wrapped using errors from `errors.go` in usecase's package you are using +- For example long-term usecase return errors wrapped with `internal/domain/long-term/errors.go` so handler can understand, what happend +- Important! errors can come with a lot of context, and if it's negative error, it's better to log it, cause it contains a lot of information about the source of error +- But handlers shouldn't just call in as HTTP response err.Error() cause it can give out private service issues + +### 8. Username normalization + +- When our service calls github api, it will work with usernames in all cases: "hurtki"/"HURTKI" +- So our github data repository is ready for this, with `username_normalized` column, that allows to update table more efficiently and allows constraints to work +- But it still contains `username` filed which contains username with actual case +- Also banners table contains normalized username to restrict creating of two banners with same username +- Also cache for stats uses + +## Main Dependencies -| Service | Purpose | Library | -| ---------- | ------------------------ | ---------------------- | -| PostgreSQL | Persistent storage | `jackc/pgx/v5` | -| GitHub API | User data source | `google/go-github/v81` | -| Kafka | Event streaming (future) | `IBM/sarama` | -| Renderer | Banner image generation | HTTP client | -| Storage | Banner file storage | HTTP client | -| Goose | Database migrations | `pressly/goose/v3` | -| Chi | HTTP routing | `go-chi/chi/v5` | -| go-cache | In-memory caching | `patrickmn/go-cache` | -| xxhash | Fast hashing | `cespare/xxhash/v2` | -| singleflight | Request deduplication | `golang.org/x/sync/singleflight` | +| Service | Purpose | Library | +| ------------ | ------------------------ | -------------------------------- | +| PostgreSQL | Persistent storage | `jackc/pgx/v5` | +| GitHub API | User data source | `google/go-github/v81` | +| Kafka | Event streaming (future) | `IBM/sarama` | +| Renderer | Banner image generation | HTTP client | +| Storage | Banner file storage | HTTP client | +| Goose | Database migrations | `pressly/goose/v3` | +| Chi | HTTP routing | `go-chi/chi/v5` | +| go-cache | In-memory caching | `patrickmn/go-cache` | +| xxhash | Fast hashing | `cespare/xxhash/v2` | +| singleflight | Request deduplication | `golang.org/x/sync/singleflight` | -## Inter-Service Communication +## Inter-Service Communication ( not implemented on handlers side ) -Services communicate via HTTP with HMAC-based authentication: +> Now everything is started in docker compose network, that allows not to secure inter-services communucations + +Services will communicate via HTTP with HMAC-based authentication: ``` API Service ──▶ HMAC Signer (signs request with timestamp + secret) @@ -262,6 +90,8 @@ API Service ──▶ HMAC Signer (signs request with timestamp + secret) ``` Headers added: -- `X-Service`: Service identifier (e.g., "api") + +- `X-Signature`: HMAC-SHA256 of "method[\n]url_path[/n]timestamp[/n]service_name" - `X-Timestamp`: Unix timestamp -- `X-Signature`: HMAC-SHA256 of "service:timestamp" \ No newline at end of file +- `X-Service`: Service identifier (e.g., "api") + diff --git a/api/docs/image.png b/api/docs/image.png deleted file mode 100644 index 9aada94cf1da55b151855a3e79746c7be7dc3d6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47596 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 Date: Sat, 14 Mar 2026 19:21:41 +0200 Subject: [PATCH 12/18] add: normalization of username to banners prewview cache --- api/internal/cache/preview.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/internal/cache/preview.go b/api/internal/cache/preview.go index 682ce9f..37e1290 100644 --- a/api/internal/cache/preview.go +++ b/api/internal/cache/preview.go @@ -1,6 +1,7 @@ package cache import ( + "strings" "time" "github.com/hurtki/github-banners/api/internal/domain" @@ -26,8 +27,11 @@ func NewPreviewMemoryCache(ttl time.Duration) *PreviewMemoryCache { // Get gets rendered banner from cache and returns it // Second return is hash, that will be the same for same bannerInfo ( excluding FetchedAt field, for different FetchedAt fields it will be same, if other fields are the same) -// Third return is found, if found is false, then banner pointer is nil ( but hash is valid ) +// Third return is found: if found is false -> banner pointer is nil ( but hash is valid ) +// It uses lower case of username in cache key, so hurtki and HURTKI as usernames are same func (c *PreviewMemoryCache) Get(bf domain.BannerInfo) (*domain.Banner, string, bool) { + bf.Username = strings.ToLower(bf.Username) + hashKey := c.hashCounter.Hash(bf) if item, found := c.cache.Get(hashKey); found { if banner, ok := item.(*domain.Banner); ok { From cbe3391d122ebd3c760f1b0e9aed7f686f8c36d2 Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Sun, 15 Mar 2026 17:59:40 +0200 Subject: [PATCH 13/18] fix: correct migrations and improve banners deduplication --- api/internal/domain/user_stats/service.go | 4 ++-- .../migrations/004_add_username_normalized_field.sql | 8 +++++--- .../005_add_username_normalized_banners_table.sql | 7 ++++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/api/internal/domain/user_stats/service.go b/api/internal/domain/user_stats/service.go index d46a526..11e93d1 100644 --- a/api/internal/domain/user_stats/service.go +++ b/api/internal/domain/user_stats/service.go @@ -34,9 +34,9 @@ func (s *UserStatsService) GetStats(ctx context.Context, username string) (domai // state >10mins but <24 hours go func() { - bgCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - _, _ = s.RecalculateAndSync(bgCtx, username) + _, _ = s.RecalculateAndSync(timeoutCtx, username) }() return cached.Stats, nil } diff --git a/api/internal/migrations/004_add_username_normalized_field.sql b/api/internal/migrations/004_add_username_normalized_field.sql index b19f2fd..b166fa0 100644 --- a/api/internal/migrations/004_add_username_normalized_field.sql +++ b/api/internal/migrations/004_add_username_normalized_field.sql @@ -92,14 +92,16 @@ set schema github_data; -- +goose Down -- drop foreign key from repositories alter table github_data.repositories -drop constraint fk_respository_owner; +drop constraint fk_repository_owner; -- revert owner_username_normalized back to owner_username alter table github_data.repositories add column owner_username text; -update github_data.repositories -set owner_username = owner_username_normalized; +update github_data.repositories r +set owner_username = u.username +from github_data.users u +where r.owner_username_normalized = u.username_normalized; drop index if exists idx_repositories_owner_username; create index idx_repositories_owner_username on github_data.repositories(owner_username); diff --git a/api/internal/migrations/005_add_username_normalized_banners_table.sql b/api/internal/migrations/005_add_username_normalized_banners_table.sql index 2613ea0..c571b3e 100644 --- a/api/internal/migrations/005_add_username_normalized_banners_table.sql +++ b/api/internal/migrations/005_add_username_normalized_banners_table.sql @@ -14,10 +14,15 @@ alter column github_username_normalized set not null; alter table banners drop column github_username; +-- deduplicate delete from banners a using banners b +-- same lowered ( normalized username ) where a.github_username_normalized = b.github_username_normalized -and a.ctid > b.ctid; +-- same banner type +and a.banner_type = b.banner_type; +-- their ctids are different ( different rows ) +and a.ctid > b.ctid alter table banners add constraint banners_github_username_normalized_banner_type_key unique (github_username_normalized, banner_type); From bd914981ca66292d60f20dd2fd1e88a98d11628a Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Sun, 15 Mar 2026 22:26:03 +0200 Subject: [PATCH 14/18] move: normalization migrations to golang --- api/internal/domain/gh-username.go | 7 ++ ...=> 004_username_normalized_github_data.go} | 59 +++++++++---- ...sql => 005_username_normalized_banners.go} | 38 ++++++++- .../migrations/normalize_string_row.go | 82 +++++++++++++++++++ 4 files changed, 166 insertions(+), 20 deletions(-) create mode 100644 api/internal/domain/gh-username.go rename api/internal/migrations/{004_add_username_normalized_field.sql => 004_username_normalized_github_data.go} (77%) rename api/internal/migrations/{005_add_username_normalized_banners_table.sql => 005_username_normalized_banners.go} (63%) create mode 100644 api/internal/migrations/normalize_string_row.go diff --git a/api/internal/domain/gh-username.go b/api/internal/domain/gh-username.go new file mode 100644 index 0000000..fd3cda6 --- /dev/null +++ b/api/internal/domain/gh-username.go @@ -0,0 +1,7 @@ +package domain + +import "strings" + +func NormalizeGithubUsername(username string) string { + return strings.ToLower(username) +} diff --git a/api/internal/migrations/004_add_username_normalized_field.sql b/api/internal/migrations/004_username_normalized_github_data.go similarity index 77% rename from api/internal/migrations/004_add_username_normalized_field.sql rename to api/internal/migrations/004_username_normalized_github_data.go index b166fa0..ba7b9d8 100644 --- a/api/internal/migrations/004_add_username_normalized_field.sql +++ b/api/internal/migrations/004_username_normalized_github_data.go @@ -1,16 +1,24 @@ --- +goose Up +package migrations +import ( + "context" + "database/sql" + + "github.com/hurtki/github-banners/api/internal/domain" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upUsernameNormalizedGithubData, downUsernameNormalizedGithubData) +} + +func upUsernameNormalizedGithubData(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` -- deleting foreign key constraint from repositories table -- to escape conflicts, because now we will change users table alter table repositories drop constraint fk_repository_owner; --- FIELD username_normalized for github data users table -delete from users a -using users b -where lower(a.username) = lower(b.username) -and a.ctid > b.ctid; - alter table users drop constraint users_pkey; @@ -18,9 +26,21 @@ drop index if exists idx_users_username; alter table users add column username_normalized text; +`) + if err != nil { + return err + } -update users -set username_normalized = lower(username); + err = normalizeStringRow(ctx, tx, "users", "username", "username_normalized", domain.NormalizeGithubUsername) + if err != nil { + return err + } + + _, err = tx.ExecContext(ctx, ` +delete from users a +using users b +where a.username_normalized = b.username_normalized +and a.ctid > b.ctid; alter table users alter column username_normalized set not null; @@ -45,10 +65,14 @@ username text not null -- normalize repositories table alter table repositories add column owner_username_normalized text; +`) -update repositories -set owner_username_normalized = lower(owner_username); + err = normalizeStringRow(ctx, tx, "repositories", "owner_username", "owner_username_normalized", domain.NormalizeGithubUsername) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, ` -- now delete ownwer_username column -- real username should be stored in users table, repositories table can't contain this drop index if exists idx_repositories_owner_username; @@ -75,7 +99,6 @@ owner_username_normalized text not null fk constraint */ - -- create schema for better separation of gihub data and our service data -- cause right now "users" create schema github_data; @@ -85,11 +108,12 @@ set schema github_data; alter table repositories set schema github_data; + `) + return err +} - - - --- +goose Down +func downUsernameNormalizedGithubData(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` -- drop foreign key from repositories alter table github_data.repositories drop constraint fk_repository_owner; @@ -141,3 +165,6 @@ set schema public; -- optional: drop schema github_data if empty -- drop schema github_data; + `) + return err +} diff --git a/api/internal/migrations/005_add_username_normalized_banners_table.sql b/api/internal/migrations/005_username_normalized_banners.go similarity index 63% rename from api/internal/migrations/005_add_username_normalized_banners_table.sql rename to api/internal/migrations/005_username_normalized_banners.go index c571b3e..d3dd7a2 100644 --- a/api/internal/migrations/005_add_username_normalized_banners_table.sql +++ b/api/internal/migrations/005_username_normalized_banners.go @@ -1,13 +1,36 @@ --- +goose Up +package migrations + +import ( + "context" + "database/sql" + + "github.com/hurtki/github-banners/api/internal/domain" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upUsernameNormalizedBanners, downUsernameNormalizedBanners) +} + +func upUsernameNormalizedBanners(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table banners drop constraint banners_github_username_banner_type_key; alter table banners add column github_username_normalized text; +`) -update banners -set github_username_normalized = lower(github_username); + if err != nil { + return err + } + + err = normalizeStringRow(ctx, tx, "banners", "github_username", "github_username_normalized", domain.NormalizeGithubUsername) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, ` alter table banners alter column github_username_normalized set not null; @@ -29,8 +52,12 @@ add constraint banners_github_username_normalized_banner_type_key unique (github create index idx_banners_username_normalized on banners (github_username_normalized); + `) + return err +} --- +goose Down +func downUsernameNormalizedBanners(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` alter table banners drop constraint banners_github_username_normalized_banner_type_key; @@ -51,3 +78,6 @@ drop column github_username_normalized; alter table banners add constraint banners_github_username_banner_type_key unique (github_username, banner_type); + `) + return err +} diff --git a/api/internal/migrations/normalize_string_row.go b/api/internal/migrations/normalize_string_row.go new file mode 100644 index 0000000..323be5e --- /dev/null +++ b/api/internal/migrations/normalize_string_row.go @@ -0,0 +1,82 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +type stringNormalizationEntry struct { + srcRow string + normalizedRow string +} + +func normalizeStringRow( + ctx context.Context, + tx *sql.Tx, + tableName string, + srcRowName string, + destRowName string, + normalizeFunc func(string) string, +) error { + rows, err := tx.QueryContext(ctx, fmt.Sprintf("select %s from %s", srcRowName, tableName)) + if err != nil { + return err + } + defer rows.Close() + + entries := make([]stringNormalizationEntry, 0) + + for rows.Next() { + var entry stringNormalizationEntry + if err := rows.Scan(&entry.srcRow); err != nil { + return err + } + entry.normalizedRow = normalizeFunc(entry.srcRow) + entries = append(entries, entry) + } + + if err := rows.Err(); err != nil { + return err + } + + batchLength := 32767 + // max postgres positiona args count: 65535 ( / 2 = 32767.5 ) + // we are having two pos args per entry => 32767 optimal + for i := 0; i < len(entries); i += batchLength { + end := min(i+batchLength, len(entries)) + + chunk := entries[i:end] + + // process chunk + + if len(chunk) == 0 { + return nil + } + var posArgs []string + var values []any + + for i, e := range chunk { + posArgs = append(posArgs, fmt.Sprintf("($%d, $%d)", (i*2)+1, (i*2)+2)) + values = append(values, e.srcRow, e.normalizedRow) + } + + query := fmt.Sprintf(` + update %s as t + set %s = v.normalized + from (values %s) as v(src, normalized) + where t.%s = v.src; + `, tableName, destRowName, strings.Join(posArgs, ","), srcRowName) + + _, err := tx.ExecContext(ctx, query, values...) + + // process end + + if err != nil { + return err + } + } + return nil + +} From 79929d467f84013aacde176c7a33c6ac15b9a865 Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Sun, 15 Mar 2026 22:37:25 +0200 Subject: [PATCH 15/18] chore: use normalization func instead of strings.ToLower() --- api/internal/cache/preview.go | 3 +- api/internal/cache/stats.go | 6 ++-- api/internal/domain/gh-username.go | 3 ++ api/internal/repo/banners/postgres_queries.go | 12 ++++---- api/internal/repo/github_user_data/get.go | 8 ++--- .../repo/github_user_data/repos_upsert.go | 12 ++------ api/internal/repo/github_user_data/save.go | 12 ++++---- .../repo/github_user_data/storage_test.go | 29 +++++++++---------- 8 files changed, 39 insertions(+), 46 deletions(-) diff --git a/api/internal/cache/preview.go b/api/internal/cache/preview.go index 37e1290..c7cae59 100644 --- a/api/internal/cache/preview.go +++ b/api/internal/cache/preview.go @@ -1,7 +1,6 @@ package cache import ( - "strings" "time" "github.com/hurtki/github-banners/api/internal/domain" @@ -30,7 +29,7 @@ func NewPreviewMemoryCache(ttl time.Duration) *PreviewMemoryCache { // Third return is found: if found is false -> banner pointer is nil ( but hash is valid ) // It uses lower case of username in cache key, so hurtki and HURTKI as usernames are same func (c *PreviewMemoryCache) Get(bf domain.BannerInfo) (*domain.Banner, string, bool) { - bf.Username = strings.ToLower(bf.Username) + bf.Username = domain.NormalizeGithubUsername(bf.Username) hashKey := c.hashCounter.Hash(bf) if item, found := c.cache.Get(hashKey); found { diff --git a/api/internal/cache/stats.go b/api/internal/cache/stats.go index f8b2041..5262cec 100644 --- a/api/internal/cache/stats.go +++ b/api/internal/cache/stats.go @@ -1,9 +1,9 @@ package cache import ( - "strings" "time" + "github.com/hurtki/github-banners/api/internal/domain" userstats "github.com/hurtki/github-banners/api/internal/domain/user_stats" "github.com/patrickmn/go-cache" ) @@ -21,7 +21,7 @@ func NewStatsMemoryCache(defaultTTL time.Duration) *StatsMemoryCache { } func (c *StatsMemoryCache) Get(username string) (*userstats.CachedStats, bool) { - normalizedUsername := strings.ToLower(username) + normalizedUsername := domain.NormalizeGithubUsername(username) if item, found := c.cache.Get(normalizedUsername); found { if stats, ok := item.(*userstats.CachedStats); ok { @@ -32,7 +32,7 @@ func (c *StatsMemoryCache) Get(username string) (*userstats.CachedStats, bool) { } func (c *StatsMemoryCache) Set(username string, entry *userstats.CachedStats, ttl time.Duration) { - normalizedUsername := strings.ToLower(username) + normalizedUsername := domain.NormalizeGithubUsername(username) c.cache.Set(normalizedUsername, entry, ttl) } diff --git a/api/internal/domain/gh-username.go b/api/internal/domain/gh-username.go index fd3cda6..38d9c27 100644 --- a/api/internal/domain/gh-username.go +++ b/api/internal/domain/gh-username.go @@ -2,6 +2,9 @@ package domain import "strings" +// NormalizeGithubUsername is the only source of +// github username normalization +// used in repository level, migrations, cache level, domain surroundings func NormalizeGithubUsername(username string) string { return strings.ToLower(username) } diff --git a/api/internal/repo/banners/postgres_queries.go b/api/internal/repo/banners/postgres_queries.go index 44ae1bb..37f8c57 100644 --- a/api/internal/repo/banners/postgres_queries.go +++ b/api/internal/repo/banners/postgres_queries.go @@ -68,13 +68,13 @@ func (r *PostgresRepo) SaveBanner(ctx context.Context, b domain.LTBannerMetadata const q = ` insert into banners (github_username_normalized, banner_type, storage_path, is_active) - values (lower($1), $2, $3, $4) + values ($1, $2, $3, $4) on conflict (github_username_normalized, banner_type) do update set is_active = EXCLUDED.is_active, storage_path = EXCLUDED.storage_path; ` - _, err = r.db.ExecContext(ctx, q, b.Username, btStr, b.UrlPath, b.Active) + _, err = r.db.ExecContext(ctx, q, domain.NormalizeGithubUsername(b.Username), btStr, b.UrlPath, b.Active) if err != nil { if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") { @@ -95,9 +95,9 @@ func (r *PostgresRepo) DeactivateBanner(ctx context.Context, githubUsername stri const q = ` update banners set is_active = false - where github_username_normalized = lower($1) and banner_type = $2 and is_active = true` + where github_username_normalized = $1 and banner_type = $2 and is_active = true` - res, err := r.db.ExecContext(ctx, q, githubUsername, domain.BannerTypesBackward[bannerType]) + res, err := r.db.ExecContext(ctx, q, domain.NormalizeGithubUsername(githubUsername), domain.BannerTypesBackward[bannerType]) if err != nil { r.logger.Error("unexpected error when deactivating banner", "source", fn, "err", err) return repoerr.ErrRepoInternal{Note: err.Error()} @@ -119,10 +119,10 @@ func (r *PostgresRepo) GetBanner(ctx context.Context, githubUsername string, ban fn := "internal.repo.banners.PostgresRepo.GetBanner" const q = ` select storage_path, is_active from banners - where github_username_normalized = lower($1) and banner_type = $2;` + where github_username_normalized = $1 and banner_type = $2;` meta := domain.LTBannerMetadata{Username: githubUsername, BannerType: bannerType} - err := r.db.QueryRowContext(ctx, q, githubUsername, domain.BannerTypesBackward[bannerType]).Scan(&meta.UrlPath, &meta.Active) + err := r.db.QueryRowContext(ctx, q, domain.NormalizeGithubUsername(githubUsername), domain.BannerTypesBackward[bannerType]).Scan(&meta.UrlPath, &meta.Active) if err != nil { if errors.Is(err, sql.ErrNoRows) { return domain.LTBannerMetadata{}, repoerr.ErrNothingFound diff --git a/api/internal/repo/github_user_data/get.go b/api/internal/repo/github_user_data/get.go index 1c319ed..33d977b 100644 --- a/api/internal/repo/github_user_data/get.go +++ b/api/internal/repo/github_user_data/get.go @@ -34,8 +34,8 @@ func (r *GithubDataPsgrRepo) GetUserData(ctx context.Context, username string) ( row := tx.QueryRowContext(ctx, ` select username, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at from github_data.users - where username_normalized = lower($1); - `, username) + where username_normalized = $1; + `, domain.NormalizeGithubUsername(username)) data := domain.GithubUserData{} @@ -47,8 +47,8 @@ func (r *GithubDataPsgrRepo) GetUserData(ctx context.Context, username string) ( rows, err := tx.QueryContext(ctx, ` select github_id, pushed_at, updated_at, language, stars_count, is_fork, forks_count from github_data.repositories - where owner_username_normalized = lower($1); - `, username) + where owner_username_normalized = $1; + `, domain.NormalizeGithubUsername(username)) if err != nil { return domain.GithubUserData{}, r.handleError(err, fn+".selectRepositoriesQuery") diff --git a/api/internal/repo/github_user_data/repos_upsert.go b/api/internal/repo/github_user_data/repos_upsert.go index 45d164a..42ff15c 100644 --- a/api/internal/repo/github_user_data/repos_upsert.go +++ b/api/internal/repo/github_user_data/repos_upsert.go @@ -26,20 +26,12 @@ func (r *GithubDataPsgrRepo) upsertRepoBatch(ctx context.Context, tx *sql.Tx, ba for _, repo := range batch { tempPosArgs := []string{} for j := i; j < i+8; j++ { - // don't forget to use lower for OwnerUsername for normalization - // 1 (2) 3 4 5 6 7 8 - // 9 (10) 11 12 13 14 - // ... - if j%8 == 2 { - tempPosArgs = append(tempPosArgs, fmt.Sprintf("lower($%d)", j)) - } else { - tempPosArgs = append(tempPosArgs, fmt.Sprintf("$%d", j)) - } + tempPosArgs = append(tempPosArgs, fmt.Sprintf("$%d", j)) } posParams = append(posParams, fmt.Sprintf("(%s)", strings.Join(tempPosArgs, ", "))) args = append(args, repo.ID, - repo.OwnerUsername, + domain.NormalizeGithubUsername(repo.OwnerUsername), repo.PushedAt, repo.UpdatedAt, repo.Language, diff --git a/api/internal/repo/github_user_data/save.go b/api/internal/repo/github_user_data/save.go index d63c273..002ab56 100644 --- a/api/internal/repo/github_user_data/save.go +++ b/api/internal/repo/github_user_data/save.go @@ -36,7 +36,7 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G _, err = tx.ExecContext(ctx, ` insert into github_data.users (username, username_normalized, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) - values ($1, lower($2), $3, $4, $5, $6, $7, $8, $9, $10) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) on conflict (username_normalized) do update set username = EXCLUDED.username, name = EXCLUDED.name, @@ -47,7 +47,7 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G followers_count = EXCLUDED.followers_count, following_count = EXCLUDED.following_count, fetched_at = EXCLUDED.fetched_at; - `, userData.Username, userData.Username, userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt) + `, userData.Username, domain.NormalizeGithubUsername(userData.Username), userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt) if err != nil { return r.handleError(err, fn+".insertUser") } @@ -56,8 +56,8 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G if len(userData.Repositories) == 0 { _, err := tx.ExecContext(ctx, ` delete from github_data.repositories - where owner_username_normalized = lower($1); - `, userData.Username) + where owner_username_normalized = $1; + `, domain.NormalizeGithubUsername(userData.Username)) if err != nil { return r.handleError(err, fn+".execDeleteAllRepositoriesFromUser") @@ -98,7 +98,7 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G } deleteArgs := make([]any, len(userData.Repositories)+1) - deleteArgs[0] = userData.Username + deleteArgs[0] = domain.NormalizeGithubUsername(userData.Username) reposCount := len(userData.Repositories) deletePosParams := make([]string, reposCount) @@ -116,7 +116,7 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G deleteQuery := fmt.Sprintf(` delete from github_data.repositories r - where r.owner_username_normalized = lower($1) + where r.owner_username_normalized = $1 and not exists ( select 1 from (values %s) as v(github_id) diff --git a/api/internal/repo/github_user_data/storage_test.go b/api/internal/repo/github_user_data/storage_test.go index 7a1a233..ad5a8e2 100644 --- a/api/internal/repo/github_user_data/storage_test.go +++ b/api/internal/repo/github_user_data/storage_test.go @@ -2,7 +2,6 @@ package github_data_repo import ( "context" - "strings" "testing" "time" @@ -50,7 +49,7 @@ func TestSaveUserDataSucess(t *testing.T) { mock.ExpectExec(` insert into github_data.users (username, username_normalized, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) - values ($1, lower($2), $3, $4, $5, $6, $7, $8, $9, $10) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) on conflict (username_normalized) do update set username = EXCLUDED.username, name = EXCLUDED.name, @@ -61,11 +60,11 @@ func TestSaveUserDataSucess(t *testing.T) { followers_count = EXCLUDED.followers_count, following_count = EXCLUDED.following_count, fetched_at = EXCLUDED.fetched_at; - `).WithArgs(userData.Username, userData.Username, userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt).WillReturnResult(sqlmock.NewResult(1, 1)) + `).WithArgs(userData.Username, domain.NormalizeGithubUsername(userData.Username), userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(` insert into github_data.repositories (github_id, owner_username_normalized, pushed_at, updated_at, language, stars_count, is_fork, forks_count) - values ($1, lower($2), $3, $4, $5, $6, $7, $8), ($9, lower($10), $11, $12, $13, $14, $15, $16) + values ($1, $2, $3, $4, $5, $6, $7, $8), ($9, $10, $11, $12, $13, $14, $15, $16) on conflict (github_id) do update set owner_username_normalized = excluded.owner_username_normalized, pushed_at = excluded.pushed_at, @@ -74,17 +73,17 @@ func TestSaveUserDataSucess(t *testing.T) { stars_count = excluded.stars_count, is_fork = excluded.is_fork, forks_count = excluded.forks_count; - `).WithArgs(githubRepo1.ID, githubRepo1.OwnerUsername, githubRepo1.PushedAt, githubRepo1.UpdatedAt, githubRepo1.Language, githubRepo1.StarsCount, githubRepo1.Fork, githubRepo1.ForksCount, githubRepo2.ID, githubRepo2.OwnerUsername, githubRepo2.PushedAt, githubRepo2.UpdatedAt, githubRepo2.Language, githubRepo2.StarsCount, githubRepo2.Fork, githubRepo2.ForksCount).WillReturnResult(sqlmock.NewResult(1, 1)) + `).WithArgs(githubRepo1.ID, domain.NormalizeGithubUsername(githubRepo1.OwnerUsername), githubRepo1.PushedAt, githubRepo1.UpdatedAt, githubRepo1.Language, githubRepo1.StarsCount, githubRepo1.Fork, githubRepo1.ForksCount, githubRepo2.ID, domain.NormalizeGithubUsername(githubRepo2.OwnerUsername), githubRepo2.PushedAt, githubRepo2.UpdatedAt, githubRepo2.Language, githubRepo2.StarsCount, githubRepo2.Fork, githubRepo2.ForksCount).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(` delete from github_data.repositories r - where r.owner_username_normalized = lower($1) + where r.owner_username_normalized = $1 and not exists ( select 1 from (values ($2::bigint), ($3::bigint)) as v(github_id) where v.github_id = r.github_id ); - `).WithArgs(userData.Username, githubRepo1.ID, githubRepo2.ID).WillReturnResult(sqlmock.NewResult(1, 1)) + `).WithArgs(domain.NormalizeGithubUsername(userData.Username), githubRepo1.ID, githubRepo2.ID).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() @@ -104,7 +103,7 @@ func TestSaveUserDataSucessNoRepos(t *testing.T) { mock.ExpectExec(` insert into github_data.users (username, username_normalized, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at) - values ($1, lower($2), $3, $4, $5, $6, $7, $8, $9, $10) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) on conflict (username_normalized) do update set username = EXCLUDED.username, name = EXCLUDED.name, @@ -115,12 +114,12 @@ func TestSaveUserDataSucessNoRepos(t *testing.T) { followers_count = EXCLUDED.followers_count, following_count = EXCLUDED.following_count, fetched_at = EXCLUDED.fetched_at; - `).WithArgs(userData.Username, strings.ToLower(userData.Username), userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt).WillReturnResult(sqlmock.NewResult(1, 1)) + `).WithArgs(userData.Username, domain.NormalizeGithubUsername(userData.Username), userData.Name, userData.Company, userData.Location, userData.Bio, userData.PublicRepos, userData.Followers, userData.Following, userData.FetchedAt).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(` delete from github_data.repositories - where owner_username_normalized = lower($1); - `).WithArgs(userData.Username).WillReturnResult(sqlmock.NewResult(1, 1)) + where owner_username_normalized = $1; + `).WithArgs(domain.NormalizeGithubUsername(userData.Username)).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() @@ -183,13 +182,13 @@ func TestGetUserDataSuccess(t *testing.T) { mock.ExpectQuery(` select username, name, company, location, bio, public_repos_count, followers_count, following_count, fetched_at from github_data.users - where username_normalized = lower($1); - `).WithArgs(userData.Username).WillReturnRows(userRows) + where username_normalized = $1; + `).WithArgs(domain.NormalizeGithubUsername(userData.Username)).WillReturnRows(userRows) mock.ExpectQuery(` select github_id, pushed_at, updated_at, language, stars_count, is_fork, forks_count from github_data.repositories - where owner_username_normalized = lower($1); - `).WithArgs(userData.Username).WillReturnRows(githubReposRows) + where owner_username_normalized = $1; + `).WithArgs(domain.NormalizeGithubUsername(userData.Username)).WillReturnRows(githubReposRows) mock.ExpectCommit() resUserData, err := repo.GetUserData(context.TODO(), userData.Username) From f4985e77437d37e876c733d6a427eee5a29a7a6a Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Sun, 15 Mar 2026 23:23:04 +0200 Subject: [PATCH 16/18] add: integration test for username normalization migration users table --- api/go.mod | 56 ++++++++- api/go.sum | 106 ++++++++++++++++++ api/internal/infrastructure/db/connection.go | 3 +- .../gh_username_normalization_test.go | 101 +++++++++++++++++ 4 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 api/internal/migrations/gh_username_normalization_test.go diff --git a/api/go.mod b/api/go.mod index 616e589..5b9bd7b 100644 --- a/api/go.mod +++ b/api/go.mod @@ -16,16 +16,35 @@ require ( github.com/stretchr/testify v1.11.1 go.uber.org/mock v0.6.0 golang.org/x/oauth2 v0.34.0 - golang.org/x/sync v0.17.0 + golang.org/x/sync v0.19.0 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -35,16 +54,43 @@ require ( github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect - github.com/klauspost/compress v1.18.1 // indirect + github.com/klauspost/compress v1.18.2 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.2 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/testcontainers/testcontainers-go v0.41.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/net v0.46.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/go.sum b/api/go.sum index 8578888..5b5c013 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,13 +1,39 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/IBM/sarama v1.46.3 h1:njRsX6jNlnR+ClJ8XmkO+CM4unbrNr/2vB5KK6UA+IE= github.com/IBM/sarama v1.46.3/go.mod h1:GTUYiF9DMOZVe3FwyGT+dtSPceGFIgA+sPc5u6CBwko= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= @@ -16,13 +42,25 @@ github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4A github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/go-github/v81 v81.0.0 h1:hTLugQRxSLD1Yei18fk4A5eYjOGLUBKAl/VCqOfFkZc= @@ -61,24 +99,54 @@ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJk github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI= github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= @@ -91,6 +159,10 @@ github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -102,7 +174,27 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais= +github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI= +github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 h1:AOtFXssrDlLm84A2sTTR/AhvJiYbrIuCO59d+Ro9Tb0= +github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0/go.mod h1:k2a09UKhgSp6vNpliIY0QSgm4Hi7GXVTzWvWgUemu/8= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -112,6 +204,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -123,20 +217,30 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -146,6 +250,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/api/internal/infrastructure/db/connection.go b/api/internal/infrastructure/db/connection.go index 77aa9bb..6317684 100644 --- a/api/internal/infrastructure/db/connection.go +++ b/api/internal/infrastructure/db/connection.go @@ -43,7 +43,8 @@ func NewDB(conf *config.PostgresConfig, logger logger.Logger) (*sql.DB, error) { continue } - if err := db.Ping(); err != nil { + err = db.Ping() + if err != nil { logger.Warn(fmt.Sprintf("Cannot ping database, try number: %d/%d", i+1, dataBaseConnectionTriesCount), "source", fn, "err", err) db.Close() time.Sleep(retryTimeBetweenTries) diff --git a/api/internal/migrations/gh_username_normalization_test.go b/api/internal/migrations/gh_username_normalization_test.go new file mode 100644 index 0000000..486e33e --- /dev/null +++ b/api/internal/migrations/gh_username_normalization_test.go @@ -0,0 +1,101 @@ +package migrations + +import ( + "log" + "testing" + + "github.com/hurtki/github-banners/api/internal/config" + "github.com/hurtki/github-banners/api/internal/infrastructure/db" + "github.com/hurtki/github-banners/api/internal/logger" + "github.com/pressly/goose/v3" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" +) + +func Test004Success(t *testing.T) { + + dbName := "users" + dbUser := "user" + dbPassword := "password" + + postgresContainer, err := postgres.Run(t.Context(), + "postgres:15", + postgres.WithDatabase(dbName), + postgres.WithUsername(dbUser), + postgres.WithPassword(dbPassword), + postgres.BasicWaitStrategies(), + ) + + defer func() { + if err := testcontainers.TerminateContainer(postgresContainer); err != nil { + log.Printf("failed to terminate container: %s", err) + } + }() + if err != nil { + log.Printf("failed to start container: %s", err) + return + } + host, err := postgresContainer.Host(t.Context()) + require.NoError(t, err) + + port, err := postgresContainer.MappedPort(t.Context(), "5432") + require.NoError(t, err) + + cfg := config.PostgresConfig{User: dbUser, DBName: dbName, Password: dbPassword, DBHost: host, DBPort: port.Port()} + + logger := logger.NewLogger("ERROR", "TEXT") + + db, err := db.NewDB(&cfg, logger) + require.NoError(t, err) + + err = goose.UpTo(db, ".", 3) + require.NoError(t, err) + + _, err = db.ExecContext(t.Context(), `INSERT INTO users ( + username, + name, + company, + location, + bio, + public_repos_count, + followers_count, + following_count, + fetched_at +) VALUES +('hurtki', 'Ivan Petrov', 'Example Corp', 'Haifa, Israel', 'Backend developer working with Go', 42, 120, 75, NOW()), +('HURTKI', 'Ivan Petrov', 'Example Corp', 'Haifa, Israel', 'Uppercase username test', 42, 120, 75, NOW()), +('HurtKi', 'Ivan Petrov', 'Example Corp', 'Haifa, Israel', 'Mixed case username test', 42, 120, 75, NOW()), +('johnDoe', 'John Doe', 'Acme Inc', 'New York, USA', 'Software engineer', 15, 80, 20, NOW()), +('JOHNDOE', 'John Doe', 'Acme Inc', 'New York, USA', 'Uppercase duplicate test', 15, 80, 20, NOW()), +('JaneSmith', 'Jane Smith', 'TechSoft', 'London, UK', 'Full-stack developer', 27, 150, 60, NOW()), +('janesmith', 'Jane Smith', 'TechSoft', 'London, UK', 'Lowercase duplicate test', 27, 150, 60, NOW()), +('DEV_GUY', 'Alex Brown', 'Startup Labs', 'Berlin, Germany', 'Open source contributor', 9, 40, 10, NOW()), +('dev_guy', 'Alex Brown', 'Startup Labs', 'Berlin, Germany', 'Case variant with underscore', 9, 40, 10, NOW()), +('SomeUser123', 'Chris White', 'DataWorks', 'Toronto, Canada', 'Data engineer', 33, 95, 44, NOW()), +('someuser123', 'Chris White', 'DataWorks', 'Toronto, Canada', 'Case duplicate with numbers', 33, 95, 44, NOW()), +('MiXeDCaSeUser', 'Taylor Green', 'CloudNine', 'Sydney, Australia', 'Cloud infrastructure engineer', 21, 60, 18, NOW());`) + require.NoError(t, err) + + err = goose.UpTo(db, ".", 4) + require.NoError(t, err) + + rows, err := db.QueryContext(t.Context(), ` + SELECT username_normalized + FROM github_data.users + WHERE username_normalized != lower(username); +`) + require.NoError(t, err) + defer rows.Close() + + var invalid []string + for rows.Next() { + var u string + err := rows.Scan(&u) + require.NoError(t, err) + invalid = append(invalid, u) + } + + require.NoError(t, rows.Err()) + require.Empty(t, invalid, "all usernames must be normalized to lowercase") +} From d524fdbef2df11f10f8879796efa684f71e0f062 Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Mon, 16 Mar 2026 12:40:04 +0200 Subject: [PATCH 17/18] run: misspell --- api/docs/architecture.md | 4 ++-- api/internal/domain/long-term/usecase.go | 2 +- api/internal/handlers/error_response.go | 2 +- api/internal/infrastructure/github/clients_pool.go | 2 +- api/internal/infrastructure/github/fetcher.go | 2 +- api/internal/repo/errors.go | 2 +- api/internal/repo/github_user_data/get.go | 2 +- api/internal/repo/github_user_data/save.go | 2 +- api/internal/repo/github_user_data/storage_test.go | 2 +- api/internal/repo/github_user_data/usernames.go | 4 ++-- api/main.go | 2 +- renderer/internal/handlers/http/preview.go | 2 +- .../infrastructure/kafka/cg_handlers/banner_update.go | 2 +- renderer/internal/infrastructure/kafka/consumer.go | 2 +- storage/internal/handlers/error_response.go | 2 +- 15 files changed, 17 insertions(+), 17 deletions(-) diff --git a/api/docs/architecture.md b/api/docs/architecture.md index 80f1405..ef6d7b5 100644 --- a/api/docs/architecture.md +++ b/api/docs/architecture.md @@ -13,7 +13,7 @@ - **Application Layer** Workers/shedulers (`app/`) Define sheduling - **Infrastructure Layer**: External services domain uses through interfaces (`infrastructure/`) - "Helpers" for domain logic, doesn't contain any buisness logic of the service + "Helpers" for domain logic, doesn't contain any business logic of the service ### 2. Repository Pattern @@ -50,7 +50,7 @@ These repositories use unified `internal/repo/errors.go` to let domain understan ### 7. Errors flow - Errors come from domain wrapped using errors from `errors.go` in usecase's package you are using -- For example long-term usecase return errors wrapped with `internal/domain/long-term/errors.go` so handler can understand, what happend +- For example long-term usecase return errors wrapped with `internal/domain/long-term/errors.go` so handler can understand, what happened - Important! errors can come with a lot of context, and if it's negative error, it's better to log it, cause it contains a lot of information about the source of error - But handlers shouldn't just call in as HTTP response err.Error() cause it can give out private service issues diff --git a/api/internal/domain/long-term/usecase.go b/api/internal/domain/long-term/usecase.go index bebc573..6b1c0e5 100644 --- a/api/internal/domain/long-term/usecase.go +++ b/api/internal/domain/long-term/usecase.go @@ -49,7 +49,7 @@ func (u *LTBannersUsecase) CreateBanner(ctx context.Context, in CreateBannerIn) bnrMeta.UrlPath = generateUrlPath(bnrMeta.Username, bnrMeta.BannerType) bnrMeta.Active = true case errors.As(err, &errRepoInternal): - // if db internal error occured, we won't go to next services + // if db internal error occurred, we won't go to next services // because, then we could get same thing when saving a new banner and all the work will be useless return CreateBannerOut{}, ErrCantCreateBanner default: diff --git a/api/internal/handlers/error_response.go b/api/internal/handlers/error_response.go index 44ef197..c43effe 100644 --- a/api/internal/handlers/error_response.go +++ b/api/internal/handlers/error_response.go @@ -15,7 +15,7 @@ func (h *BannersHandler) error(rw http.ResponseWriter, statusCode int, message s if err != nil { h.logger.Error("can't marshal error response", "err", err, "source", fn) rw.WriteHeader(http.StatusInternalServerError) - _, err := rw.Write([]byte("{\"error\": \"server error occured\"}")) + _, err := rw.Write([]byte("{\"error\": \"server error occurred\"}")) if err != nil { h.logger.Warn("can't write error response", "err", err, "source", fn) } diff --git a/api/internal/infrastructure/github/clients_pool.go b/api/internal/infrastructure/github/clients_pool.go index 7d6305f..8106ac2 100644 --- a/api/internal/infrastructure/github/clients_pool.go +++ b/api/internal/infrastructure/github/clients_pool.go @@ -32,7 +32,7 @@ func (f *Fetcher) acquireClient(ctx context.Context) *GithubClient { rl, _, err := cl.Client.RateLimit.Get(ctx) cancel() if err != nil { - f.logger.Error("found client, that its Reset time is before Now(), error occured when getting its rate limit, skipping", "err", err, "source", fn) + f.logger.Error("found client, that its Reset time is before Now(), error occurred when getting its rate limit, skipping", "err", err, "source", fn) continue } // after net call, we are having new source of truth diff --git a/api/internal/infrastructure/github/fetcher.go b/api/internal/infrastructure/github/fetcher.go index 363e524..298ae9a 100644 --- a/api/internal/infrastructure/github/fetcher.go +++ b/api/internal/infrastructure/github/fetcher.go @@ -123,7 +123,7 @@ func (f *Fetcher) fetchRepositories(ctx context.Context, username string) ([]*gi } for { - // every page aquire a new client for one request + // every page acquire a new client for one request cl := f.acquireClient(ctx) if cl == nil { f.logger.Warn("can't find available client for github api request") diff --git a/api/internal/repo/errors.go b/api/internal/repo/errors.go index 33ff33b..479e1d8 100644 --- a/api/internal/repo/errors.go +++ b/api/internal/repo/errors.go @@ -21,7 +21,7 @@ func (e ErrEmptyField) Error() string { return fmt.Sprintf("field %s should be n type ErrRepoInternal struct{ Note string } func (e ErrRepoInternal) Error() string { - return fmt.Sprintf("internal repo occured, note: %s", e.Note) + return fmt.Sprintf("internal repo occurred, note: %s", e.Note) } var ( diff --git a/api/internal/repo/github_user_data/get.go b/api/internal/repo/github_user_data/get.go index 33d977b..e53e36b 100644 --- a/api/internal/repo/github_user_data/get.go +++ b/api/internal/repo/github_user_data/get.go @@ -27,7 +27,7 @@ func (r *GithubDataPsgrRepo) GetUserData(ctx context.Context, username string) ( if !committed { rbErr := tx.Rollback() if rbErr != nil { - r.logger.Error("error occured, when rolling back transaction", "err", rbErr, "source", fn) + r.logger.Error("error occurred, when rolling back transaction", "err", rbErr, "source", fn) } } }() diff --git a/api/internal/repo/github_user_data/save.go b/api/internal/repo/github_user_data/save.go index 002ab56..acfec9e 100644 --- a/api/internal/repo/github_user_data/save.go +++ b/api/internal/repo/github_user_data/save.go @@ -29,7 +29,7 @@ func (r *GithubDataPsgrRepo) SaveUserData(ctx context.Context, userData domain.G if !committed { rbErr := tx.Rollback() if rbErr != nil { - r.logger.Error("error occured, when rolling back transaction", "err", rbErr, "source", fn) + r.logger.Error("error occurred, when rolling back transaction", "err", rbErr, "source", fn) } } }() diff --git a/api/internal/repo/github_user_data/storage_test.go b/api/internal/repo/github_user_data/storage_test.go index ad5a8e2..18eef00 100644 --- a/api/internal/repo/github_user_data/storage_test.go +++ b/api/internal/repo/github_user_data/storage_test.go @@ -19,7 +19,7 @@ func (m LoggerMock) Warn(a string, b ...any) {} func (m LoggerMock) Error(a string, b ...any) {} func (m LoggerMock) With(a ...any) logger.Logger { return m } -// helper for testing to create sql mock, create repoistory with it +// helper for testing to create sql mock, create repository with it // checks sqlmock expectations func getMockAndRepo(t *testing.T) (sqlmock.Sqlmock, *GithubDataPsgrRepo) { db, mock, _ := sqlmock.New( diff --git a/api/internal/repo/github_user_data/usernames.go b/api/internal/repo/github_user_data/usernames.go index 2257c56..08403d7 100644 --- a/api/internal/repo/github_user_data/usernames.go +++ b/api/internal/repo/github_user_data/usernames.go @@ -19,14 +19,14 @@ func (r *GithubDataPsgrRepo) GetAllUsernames(ctx context.Context) ([]string, err username := "" err := rows.Scan(&username) if err != nil { - r.logger.Error("unexcpected error occured when scanning usernames", "source", fn, "err", err) + r.logger.Error("unexcpected error occurred when scanning usernames", "source", fn, "err", err) return nil, r.handleError(err, fn+".scanUsername") } usernames = append(usernames, username) } if err := rows.Err(); err != nil { - r.logger.Error("unexcpected error occured after scanning usernames", "source", fn, "err", err) + r.logger.Error("unexcpected error occurred after scanning usernames", "source", fn, "err", err) return nil, r.handleError(err, fn+".afterScanRowsError") } diff --git a/api/main.go b/api/main.go index 6ad2efe..40d7810 100644 --- a/api/main.go +++ b/api/main.go @@ -133,7 +133,7 @@ func main() { srv := server.New(cfg, router, logger) srv.Start() - // gracefull shutdown + // graceful shutdown quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit diff --git a/renderer/internal/handlers/http/preview.go b/renderer/internal/handlers/http/preview.go index 35be11f..8a3cb1d 100644 --- a/renderer/internal/handlers/http/preview.go +++ b/renderer/internal/handlers/http/preview.go @@ -65,7 +65,7 @@ func (h *PreviewHandler) error(rw http.ResponseWriter, statusCode int, message s if err != nil { h.logger.Error("can't marshal error response", "err", err, "source", fn) rw.WriteHeader(http.StatusInternalServerError) - _, err := rw.Write([]byte("{\"error\": \"server error occured\"}")) + _, err := rw.Write([]byte("{\"error\": \"server error occurred\"}")) if err != nil { h.logger.Warn("can't write error response", "err", err, "source", fn) } diff --git a/renderer/internal/infrastructure/kafka/cg_handlers/banner_update.go b/renderer/internal/infrastructure/kafka/cg_handlers/banner_update.go index e5d88bc..428e6dd 100644 --- a/renderer/internal/infrastructure/kafka/cg_handlers/banner_update.go +++ b/renderer/internal/infrastructure/kafka/cg_handlers/banner_update.go @@ -64,7 +64,7 @@ func (h *BannerUpdateCGHandler) ConsumeClaim(session sarama.ConsumerGroupSession for range h.cfg.EventsBatchSize { select { - // if session context done, exiting immediatly + // if session context done, exiting immediately case <-session.Context().Done(): cancel() return nil diff --git a/renderer/internal/infrastructure/kafka/consumer.go b/renderer/internal/infrastructure/kafka/consumer.go index eee33d8..5991a0f 100644 --- a/renderer/internal/infrastructure/kafka/consumer.go +++ b/renderer/internal/infrastructure/kafka/consumer.go @@ -70,7 +70,7 @@ func (c *KafkaConsumerGroup) RegisterCGHandler(topics []string, handler sarama.C func (c *KafkaConsumerGroup) registerCGHandler(topics []string, handler sarama.ConsumerGroupHandler) { err := c.cg.Consume(c.ctx, topics, handler) if err != nil { - c.logger.Error("error occured, when registering consumer group handler", "err", err, "topics", topics) + c.logger.Error("error occurred, when registering consumer group handler", "err", err, "topics", topics) } } diff --git a/storage/internal/handlers/error_response.go b/storage/internal/handlers/error_response.go index 2fba382..8c7b46a 100644 --- a/storage/internal/handlers/error_response.go +++ b/storage/internal/handlers/error_response.go @@ -15,7 +15,7 @@ func (h *BannerSaveHandler) error(rw http.ResponseWriter, statusCode int, messag if err != nil { h.logger.Error("can't marshal error response", "err", err, "source", fn) rw.WriteHeader(http.StatusInternalServerError) - _, err := rw.Write([]byte("{\"error\": \"server error occured\"}")) + _, err := rw.Write([]byte("{\"error\": \"server error occurred\"}")) if err != nil { h.logger.Warn("can't write error response", "err", err, "source", fn) } From f134fa66aa335f82903df2f43ec3759a7d7b4cf5 Mon Sep 17 00:00:00 2001 From: uno/hurtki Date: Tue, 17 Mar 2026 11:11:28 +0200 Subject: [PATCH 18/18] fix: json key on banner creation endpoint + add: public demo url --- api/docs/api.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/docs/api.yaml b/api/docs/api.yaml index 2eea8f0..e6d9f5f 100644 --- a/api/docs/api.yaml +++ b/api/docs/api.yaml @@ -6,6 +6,8 @@ info: servers: - url: http://localhost:8080 description: Local development server + - url: https://api.bnrs.dev + description: Public demo paths: /banners/preview: get: @@ -142,15 +144,14 @@ components: type: object required: - username - - banner_type + - type properties: username: type: string description: GitHub username example: torvalds - banner_type: + type: type: string enum: [dark, default] description: Type of banner to create example: dark -