diff --git a/.kilo/kilo.jsonc b/.kilo/kilo.jsonc new file mode 100644 index 0000000..571a15b --- /dev/null +++ b/.kilo/kilo.jsonc @@ -0,0 +1,3 @@ +{ + "$schema": "https://app.kilo.ai/config.json" +} diff --git a/.kilo/skills b/.kilo/skills new file mode 120000 index 0000000..d936014 --- /dev/null +++ b/.kilo/skills @@ -0,0 +1 @@ +/Users/rmyers/workspace/julython.org/.pi/skills \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/internal/api/router.go b/internal/api/router.go index 6fc881d..845cf46 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/metrics/service.go b/internal/metrics/service.go index de40ce2..ecf9c21 100644 --- a/internal/metrics/service.go +++ b/internal/metrics/service.go @@ -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). @@ -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 diff --git a/internal/services/game.go b/internal/services/game.go index e3b32a6..203be3c 100644 --- a/internal/services/game.go +++ b/internal/services/game.go @@ -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) + } } } @@ -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 -} diff --git a/internal/services/game_test.go b/internal/services/game_test.go index 77ca83f..70b2f0f 100644 --- a/internal/services/game_test.go +++ b/internal/services/game_test.go @@ -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)") +} diff --git a/internal/webhooks/github.go b/internal/webhooks/github.go index 94ac750..cd83da2 100644 --- a/internal/webhooks/github.go +++ b/internal/webhooks/github.go @@ -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, "/", "-") } @@ -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) { @@ -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