diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go index 7573baa..70ed111 100644 --- a/internal/bugseti/hub.go +++ b/internal/bugseti/hub.go @@ -216,6 +216,8 @@ func (h *HubService) savePendingOps() {} // drainPendingOps replays queued operations (no-op until Task 7). func (h *HubService) drainPendingOps() {} +// ---- Task 4: Auto-Register via Forge Token ---- + // AutoRegister exchanges a Forge API token for a hub API key. // If a hub token is already configured, this is a no-op. func (h *HubService) AutoRegister() error { @@ -290,6 +292,8 @@ func (h *HubService) AutoRegister() error { return nil } +// ---- Task 5: Write Operations ---- + // Register registers this client with the hub. func (h *HubService) Register() error { h.drainPendingOps() @@ -382,18 +386,83 @@ func (h *HubService) SyncStats(stats *Stats) error { body := map[string]interface{}{ "client_id": h.config.GetClientID(), "stats": map[string]interface{}{ - "issues_attempted": stats.IssuesAttempted, - "issues_completed": stats.IssuesCompleted, - "issues_skipped": stats.IssuesSkipped, - "prs_submitted": stats.PRsSubmitted, - "prs_merged": stats.PRsMerged, - "prs_rejected": stats.PRsRejected, - "current_streak": stats.CurrentStreak, - "longest_streak": stats.LongestStreak, + "issues_attempted": stats.IssuesAttempted, + "issues_completed": stats.IssuesCompleted, + "issues_skipped": stats.IssuesSkipped, + "prs_submitted": stats.PRsSubmitted, + "prs_merged": stats.PRsMerged, + "prs_rejected": stats.PRsRejected, + "current_streak": stats.CurrentStreak, + "longest_streak": stats.LongestStreak, "total_time_minutes": int(stats.TotalTimeSpent.Minutes()), - "repos_contributed": repos, + "repos_contributed": repos, }, } return h.doJSON("POST", "/stats/sync", body, nil) } + +// ---- Task 6: Read Operations ---- + +// IsIssueClaimed checks whether an issue is currently claimed on the hub. +// Returns the claim if it exists, or (nil, nil) if the issue is not claimed (404). +func (h *HubService) IsIssueClaimed(issueID string) (*HubClaim, error) { + path := "/issues/" + url.PathEscape(issueID) + + var claim HubClaim + if err := h.doJSON("GET", path, nil, &claim); err != nil { + if _, ok := err.(*NotFoundError); ok { + return nil, nil + } + return nil, err + } + return &claim, nil +} + +// ListClaims returns claimed issues, optionally filtered by status and/or repo. +func (h *HubService) ListClaims(status, repo string) ([]*HubClaim, error) { + params := url.Values{} + if status != "" { + params.Set("status", status) + } + if repo != "" { + params.Set("repo", repo) + } + + path := "/issues/claimed" + if encoded := params.Encode(); encoded != "" { + path += "?" + encoded + } + + var claims []*HubClaim + if err := h.doJSON("GET", path, nil, &claims); err != nil { + return nil, err + } + return claims, nil +} + +// leaderboardResponse wraps the hub leaderboard JSON envelope. +type leaderboardResponse struct { + Entries []LeaderboardEntry `json:"entries"` + TotalParticipants int `json:"totalParticipants"` +} + +// GetLeaderboard fetches the top N leaderboard entries from the hub. +func (h *HubService) GetLeaderboard(limit int) ([]LeaderboardEntry, int, error) { + path := fmt.Sprintf("/leaderboard?limit=%d", limit) + + var resp leaderboardResponse + if err := h.doJSON("GET", path, nil, &resp); err != nil { + return nil, 0, err + } + return resp.Entries, resp.TotalParticipants, nil +} + +// GetGlobalStats fetches aggregate statistics from the hub. +func (h *HubService) GetGlobalStats() (*GlobalStats, error) { + var stats GlobalStats + if err := h.doJSON("GET", "/stats", nil, &stats); err != nil { + return nil, err + } + return &stats, nil +} diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go index 263e688..206b34b 100644 --- a/internal/bugseti/hub_test.go +++ b/internal/bugseti/hub_test.go @@ -111,7 +111,7 @@ func TestDoRequest_Bad_NetworkError(t *testing.T) { assert.False(t, h.IsConnected()) } -// ---- AutoRegister ---- +// ---- Task 4: AutoRegister ---- func TestAutoRegister_Good(t *testing.T) { var gotBody map[string]string @@ -176,7 +176,7 @@ func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) { assert.Equal(t, "existing-token", h.config.GetHubToken()) } -// ---- Write Operations ---- +// ---- Task 5: Write Operations ---- func TestRegister_Good(t *testing.T) { var gotPath string @@ -378,3 +378,116 @@ func TestSyncStats_Good(t *testing.T) { require.True(t, ok) assert.Len(t, reposRaw, 2) } + +// ---- Task 6: Read Operations ---- + +func TestIsIssueClaimed_Good_Claimed(t *testing.T) { + now := time.Now().Truncate(time.Second) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/bugseti/issues/issue-42", r.URL.Path) + assert.Equal(t, "GET", r.Method) + + w.WriteHeader(http.StatusOK) + claim := HubClaim{ + ID: "claim-1", + IssueURL: "https://github.com/org/repo/issues/42", + ClientID: "client-abc", + ClaimedAt: now, + Status: "claimed", + } + _ = json.NewEncoder(w).Encode(claim) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + claim, err := h.IsIssueClaimed("issue-42") + require.NoError(t, err) + require.NotNil(t, claim) + assert.Equal(t, "claim-1", claim.ID) + assert.Equal(t, "claimed", claim.Status) +} + +func TestIsIssueClaimed_Good_NotClaimed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + claim, err := h.IsIssueClaimed("issue-999") + assert.NoError(t, err) + assert.Nil(t, claim) +} + +func TestGetLeaderboard_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/bugseti/leaderboard", r.URL.Path) + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "10", r.URL.Query().Get("limit")) + + resp := leaderboardResponse{ + Entries: []LeaderboardEntry{ + {ClientID: "a", ClientName: "Alice", Score: 100, PRsMerged: 10, Rank: 1}, + {ClientID: "b", ClientName: "Bob", Score: 80, PRsMerged: 8, Rank: 2}, + }, + TotalParticipants: 42, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + entries, total, err := h.GetLeaderboard(10) + require.NoError(t, err) + assert.Equal(t, 42, total) + require.Len(t, entries, 2) + assert.Equal(t, "Alice", entries[0].ClientName) + assert.Equal(t, 1, entries[0].Rank) + assert.Equal(t, "Bob", entries[1].ClientName) +} + +func TestGetGlobalStats_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/bugseti/stats", r.URL.Path) + assert.Equal(t, "GET", r.Method) + + stats := GlobalStats{ + TotalClients: 100, + TotalClaims: 500, + TotalPRsMerged: 300, + ActiveClaims: 25, + IssuesAvailable: 150, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(stats) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + stats, err := h.GetGlobalStats() + require.NoError(t, err) + require.NotNil(t, stats) + assert.Equal(t, 100, stats.TotalClients) + assert.Equal(t, 500, stats.TotalClaims) + assert.Equal(t, 300, stats.TotalPRsMerged) + assert.Equal(t, 25, stats.ActiveClaims) + assert.Equal(t, 150, stats.IssuesAvailable) +}