Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .kilo/kilo.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"$schema": "https://app.kilo.ai/config.json"
}
1 change: 1 addition & 0 deletions .kilo/skills
1 change: 1 addition & 0 deletions AGENTS.md
2 changes: 1 addition & 1 deletion internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func buildMux(pool *pgxpool.Pool, cfg *config.Config) (
queries := db.New(pool)
userSvc := services.MustNewUserService(queries, cfg.Database.EncKey)
gameSvc := services.NewGameService(queries)
scanner := metrics.NewScanner(queries, pool, gameSvc, cfg.GitHubToken)
scanner := metrics.NewScanner(queries, pool, cfg.GitHubToken)

// OAuth providers
providers := make(map[string]services.OAuthProvider)
Expand Down
20 changes: 5 additions & 15 deletions internal/metrics/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,19 @@ import (
"github.com/rs/zerolog/log"

"july/internal/db"
"july/internal/services"
)

// Scanner runs server-side L1 analysis and upserts analysis_metrics rows.
type Scanner struct {
queries *db.Queries
pool *pgxpool.Pool
gameService *services.GameService
token string
queries *db.Queries
pool *pgxpool.Pool
token string
}

// NewScanner wires the app-wide queries handle and pool from the composition root (same as api.buildMux).
// Pool is used only for transactions; queries is used for pool-scoped SQL (e.g. SetProjectIsPrivate).
func NewScanner(queries *db.Queries, pool *pgxpool.Pool, gs *services.GameService, token string) *Scanner {
return &Scanner{queries: queries, pool: pool, gameService: gs, token: token}
func NewScanner(queries *db.Queries, pool *pgxpool.Pool, token string) *Scanner {
return &Scanner{queries: queries, pool: pool, token: token}
}

// IsConfigured returns true when a non-empty GitHub token was provided (public-repo reads).
Expand Down Expand Up @@ -129,14 +127,6 @@ func (s *Scanner) RunScan(ctx context.Context, project db.Project, updatedBy uui
}); err != nil {
log.Warn().Err(err).Str("project", project.Slug).Msg("failed to update board verified_points")
}

// Refresh the player who owns this project's board.
game, err := s.gameService.GetActiveGame(ctx)
if err == nil {
if err := s.gameService.RefreshPlayerAfterScan(ctx, game.ID, project.ID); err != nil {
log.Warn().Err(err).Str("project", project.Slug).Msg("failed to refresh player after scan")
}
}
}

return nil
Expand Down
127 changes: 39 additions & 88 deletions internal/services/game.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,45 +305,51 @@ func (s *GameService) assignPlayerBoards(ctx context.Context, game db.Game, user
boardCount++
}

// Only assign if the player has fewer than 3 boards.
// Only assign if the player has fewer than 3 boards and the board
// isn't already assigned to this player.
if boardCount < 3 {
var board1ID, board2ID, board3ID pgtype.UUID
if ids.Board1ID.Valid {
board1ID = ids.Board1ID
}
if ids.Board2ID.Valid {
board2ID = ids.Board2ID
}
if ids.Board3ID.Valid {
board3ID = ids.Board3ID
}
// Assign to the first available slot.
switch boardCount {
case 0:
board1ID = pgid(boardID)
case 1:
alreadyAssigned := (ids.Board1ID.Valid && ids.Board1ID == pgid(boardID)) ||
(ids.Board2ID.Valid && ids.Board2ID == pgid(boardID)) ||
(ids.Board3ID.Valid && ids.Board3ID == pgid(boardID))
if !alreadyAssigned {
var board1ID, board2ID, board3ID pgtype.UUID
if ids.Board1ID.Valid {
board2ID = pgid(boardID)
} else {
board1ID = pgid(boardID)
board1ID = ids.Board1ID
}
if ids.Board2ID.Valid {
board2ID = ids.Board2ID
}
case 2:
if ids.Board1ID.Valid && ids.Board2ID.Valid {
board3ID = pgid(boardID)
} else if ids.Board1ID.Valid {
board2ID = pgid(boardID)
} else {
if ids.Board3ID.Valid {
board3ID = ids.Board3ID
}
// Assign to the first available slot.
switch boardCount {
case 0:
board1ID = pgid(boardID)
case 1:
if ids.Board1ID.Valid {
board2ID = pgid(boardID)
} else {
board1ID = pgid(boardID)
}
case 2:
if ids.Board1ID.Valid && ids.Board2ID.Valid {
board3ID = pgid(boardID)
} else if ids.Board1ID.Valid {
board2ID = pgid(boardID)
} else {
board1ID = pgid(boardID)
}
}
}

if _, err := s.queries.AssignBoards(ctx, db.AssignBoardsParams{
PlayerID: player.ID,
Board1ID: board1ID,
Board2ID: board2ID,
Board3ID: board3ID,
}); err != nil {
return fmt.Errorf("assign boards: %w", err)
if _, err := s.queries.AssignBoards(ctx, db.AssignBoardsParams{
PlayerID: player.ID,
Board1ID: board1ID,
Board2ID: board2ID,
Board3ID: board3ID,
}); err != nil {
return fmt.Errorf("assign boards: %w", err)
}
}
}

Expand Down Expand Up @@ -523,58 +529,3 @@ func (s *GameService) DeactivateProject(ctx context.Context, projectID uuid.UUID
return nil
}

// RefreshPlayerAfterScan updates the player who owns the given project's board.
// It recalculates verified_points = (commit_count × 1) + Sum(3 boards).
func (s *GameService) RefreshPlayerAfterScan(ctx context.Context, gameID, projectID uuid.UUID) error {
// 1. Get the board for this project.
board, err := s.queries.GetBoardByProjectAndGame(ctx, db.GetBoardByProjectAndGameParams{
ProjectID: projectID,
GameID: gameID,
})
if err != nil {
return nil // no board, skip silently
}

// 2. Find the player who has this board.
player, err := s.queries.GetPlayerByBoardID(ctx, db.UUID(board.ID))
if err != nil {
return nil // no player, skip silently
}

// 3. Get the user's commit count for the game.
counts, err := s.queries.CountUserCommitsForGame(ctx, db.CountUserCommitsForGameParams{
UserID: db.UUID(player.UserID),
GameID: db.UUID(gameID),
})
if err != nil {
return fmt.Errorf("count commits: %w", err)
}

// 4. Get the total from all 3 boards.
boardIDs, err := s.queries.GetPlayerBoardIds(ctx, player.ID)
if err != nil {
return fmt.Errorf("get board IDs: %w", err)
}

boardTotal, err := s.queries.GetPlayerBoardTotal(ctx, db.GetPlayerBoardTotalParams{
Board1ID: db.UUIDFromPg(boardIDs.Board1ID),
Board2ID: db.UUIDFromPg(boardIDs.Board2ID),
Board3ID: db.UUIDFromPg(boardIDs.Board3ID),
})
if err != nil {
return fmt.Errorf("get board total: %w", err)
}

// 5. Update the player: verified_points = commit_count + board_total.
verifiedPoints := int32(counts.CommitCount) + boardTotal
if err := s.queries.UpdatePlayerAnalysis(ctx, db.UpdatePlayerAnalysisParams{
ID: player.ID,
VerifiedPoints: verifiedPoints,
CommitCount: counts.CommitCount,
ProjectCount: counts.ProjectCount,
AnalysisStatus: "completed",
}); err != nil {
return fmt.Errorf("update player: %w", err)
}
return nil
}
48 changes: 48 additions & 0 deletions internal/services/game_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -638,3 +638,51 @@ func TestGameService_ScoreReset(t *testing.T) {
"total should be 17 (points 12 + verified_points 5)")
})
}

// TestGameService_AddCommit_DuplicateBoard verifies that calling AddCommit
// for commits belonging to the same project does not assign the same board
// to multiple player slots.
func TestGameService_AddCommit_DuplicateBoard(t *testing.T) {
env := testutil.SetupTestEnv(t)
ctx := context.Background()

game := testutil.CreateActiveGame(t, env)

// First commit: AddCommit assigns the board to slot 1.
testutil.WebhookCommit(t, env, "dup-hash-001", func(o *testutil.WebhookOpts) {
o.RepoID = 66666
o.RepoName = "duplicate-board-project"
o.FullName = "dupuser/duplicate-board-project"
o.HTMLURL = "https://github.com/dupuser/duplicate-board-project"
o.Owner = "dupuser"
o.Author = webhooks.GitHubAuthor{Name: "Dup User", Email: "dupuser@test.com"}
})

// Second commit: AddCommit again for the same project.
// The fix should skip re-assigning the same board.
testutil.WebhookCommit(t, env, "dup-hash-002", func(o *testutil.WebhookOpts) {
o.RepoID = 66666
o.RepoName = "duplicate-board-project"
o.FullName = "dupuser/duplicate-board-project"
o.HTMLURL = "https://github.com/dupuser/duplicate-board-project"
o.Owner = "dupuser"
o.Author = webhooks.GitHubAuthor{Name: "Dup User", Email: "dupuser@test.com"}
})

// Look up the player via username and verify they have exactly 1 board.
user, err := env.Queries.GetUserByUsername(ctx, "gh-dupuser")
require.NoError(t, err)

player, err := env.Queries.GetPlayerByUserAndGame(ctx, db.GetPlayerByUserAndGameParams{
UserID: user.ID,
GameID: game.ID,
})
require.NoError(t, err)

ids, err := env.Queries.GetPlayerBoardIds(ctx, player.ID)
require.NoError(t, err)

require.True(t, ids.Board1ID.Valid, "board_1 should be set")
assert.False(t, ids.Board2ID.Valid, "board_2 should NOT be assigned (same board already in slot 1)")
assert.False(t, ids.Board3ID.Valid, "board_3 should NOT be assigned (same board already in slot 1)")
}
53 changes: 35 additions & 18 deletions internal/webhooks/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,46 +234,67 @@ func (h *Handler) HandleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
Int("skipped", result.Skipped).
Msg("processed webhook")

h.scheduleL1Scan(project)
h.scheduleL1Scan(project, result.CreatedCommits)

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result)
}

func (h *Handler) scheduleL1Scan(project db.Project) {
// scheduleL1Scan starts the L1 scan in a background goroutine and, once the
// scan finishes, calls AddCommit for each created commit so the game is scored
// with the final verified_points from the L1 analysis.
func (h *Handler) scheduleL1Scan(project db.Project, createdCommits []db.Commit) {
if len(createdCommits) == 0 {
return
}
if h.scanner == nil || h.pool == nil {
log.Warn().Msg("L1 scan skipped: handler has no pool or scanner")
h.scoreCreatedCommits(createdCommits)
return
}
if !h.scanner.IsConfigured() {
log.Warn().
Str("project", project.Slug).
Msg("L1 scan skipped: set GITHUB_TOKEN for server-side analysis (public repos)")
h.scoreCreatedCommits(createdCommits)
return
}
if project.IsPrivate {
log.Info().Str("project", project.Slug).Msg("L1 scan skipped: private repository")
h.scoreCreatedCommits(createdCommits)
return
}
proj := project
log.Info().Str("project", proj.Slug).Str("id", proj.ID.String()).Msg("L1 scan starting in background")
log.Info().Str("project", project.Slug).Str("id", project.ID.String()).Msg("L1 scan starting in background")
go func() {
start := time.Now()
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Str("project", proj.Slug).Msg("L1 scan panic")
log.Error().Interface("panic", r).Str("project", project.Slug).Msg("L1 scan panic")
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
if err := h.scanner.RunScan(ctx, proj, db.SystemUserID); err != nil {
log.Error().Err(err).Str("slug", proj.Slug).Dur("duration", time.Since(start)).Msg("L1 scan failed")
if err := h.scanner.RunScan(ctx, project, db.SystemUserID); err != nil {
log.Error().Err(err).Str("slug", project.Slug).Dur("duration", time.Since(start)).Msg("L1 scan failed")
return
}
log.Info().Str("slug", proj.Slug).Dur("duration", time.Since(start)).Msg("L1 scan completed")
log.Info().Str("slug", project.Slug).Dur("duration", time.Since(start)).Msg("L1 scan completed")
h.scoreCreatedCommits(createdCommits)
}()
}

// scoreCreatedCommits calls AddCommit for each created commit, scored after
// the L1 scan has set verified_points on the boards.
func (h *Handler) scoreCreatedCommits(commits []db.Commit) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
for _, commit := range commits {
if err := h.gameService.AddCommit(ctx, commit); err != nil {
log.Error().Err(err).Str("hash", commit.Hash.String).Msg("failed to add commit to game")
}
}
}

func githubSlug(fullName string) string {
return "gh-" + strings.ReplaceAll(fullName, "/", "-")
}
Expand All @@ -298,9 +319,10 @@ func (h *Handler) upsertProject(ctx context.Context, repo GitHubRepo) (db.Projec
}

type ProcessResult struct {
Received int `json:"received"`
Created int `json:"created"`
Skipped int `json:"skipped"`
Received int `json:"received"`
Created int `json:"created"`
Skipped int `json:"skipped"`
CreatedCommits []db.Commit `json:"-"`
}

func (h *Handler) processCommits(ctx context.Context, project db.Project, repo GitHubRepo, commits []GitHubCommit) (*ProcessResult, error) {
Expand Down Expand Up @@ -359,13 +381,8 @@ func (h *Handler) processCommits(ctx context.Context, project db.Project, repo G
continue
}

// Update game scores
if err := h.gameService.AddCommit(ctx, commit); err != nil {
logger.Error().Err(err).Str("hash", c.ID).Msg("failed to add commit to game")
// Don't fail the whole request
}

result.Created++
result.CreatedCommits = append(result.CreatedCommits, commit)
}

return result, nil
Expand Down
Loading