feat(bugseti): add hub read operations
Add IsIssueClaimed, ListClaims, GetLeaderboard, and GetGlobalStats methods. IsIssueClaimed returns (nil, nil) on 404 for unclaimed issues. GetLeaderboard returns entries and total participant count. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
a6456e2c6d
commit
7a92fe0040
2 changed files with 193 additions and 11 deletions
|
|
@ -216,6 +216,8 @@ func (h *HubService) savePendingOps() {}
|
||||||
// drainPendingOps replays queued operations (no-op until Task 7).
|
// drainPendingOps replays queued operations (no-op until Task 7).
|
||||||
func (h *HubService) drainPendingOps() {}
|
func (h *HubService) drainPendingOps() {}
|
||||||
|
|
||||||
|
// ---- Task 4: Auto-Register via Forge Token ----
|
||||||
|
|
||||||
// AutoRegister exchanges a Forge API token for a hub API key.
|
// AutoRegister exchanges a Forge API token for a hub API key.
|
||||||
// If a hub token is already configured, this is a no-op.
|
// If a hub token is already configured, this is a no-op.
|
||||||
func (h *HubService) AutoRegister() error {
|
func (h *HubService) AutoRegister() error {
|
||||||
|
|
@ -290,6 +292,8 @@ func (h *HubService) AutoRegister() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Task 5: Write Operations ----
|
||||||
|
|
||||||
// Register registers this client with the hub.
|
// Register registers this client with the hub.
|
||||||
func (h *HubService) Register() error {
|
func (h *HubService) Register() error {
|
||||||
h.drainPendingOps()
|
h.drainPendingOps()
|
||||||
|
|
@ -382,18 +386,83 @@ func (h *HubService) SyncStats(stats *Stats) error {
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"client_id": h.config.GetClientID(),
|
"client_id": h.config.GetClientID(),
|
||||||
"stats": map[string]interface{}{
|
"stats": map[string]interface{}{
|
||||||
"issues_attempted": stats.IssuesAttempted,
|
"issues_attempted": stats.IssuesAttempted,
|
||||||
"issues_completed": stats.IssuesCompleted,
|
"issues_completed": stats.IssuesCompleted,
|
||||||
"issues_skipped": stats.IssuesSkipped,
|
"issues_skipped": stats.IssuesSkipped,
|
||||||
"prs_submitted": stats.PRsSubmitted,
|
"prs_submitted": stats.PRsSubmitted,
|
||||||
"prs_merged": stats.PRsMerged,
|
"prs_merged": stats.PRsMerged,
|
||||||
"prs_rejected": stats.PRsRejected,
|
"prs_rejected": stats.PRsRejected,
|
||||||
"current_streak": stats.CurrentStreak,
|
"current_streak": stats.CurrentStreak,
|
||||||
"longest_streak": stats.LongestStreak,
|
"longest_streak": stats.LongestStreak,
|
||||||
"total_time_minutes": int(stats.TotalTimeSpent.Minutes()),
|
"total_time_minutes": int(stats.TotalTimeSpent.Minutes()),
|
||||||
"repos_contributed": repos,
|
"repos_contributed": repos,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.doJSON("POST", "/stats/sync", body, nil)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ func TestDoRequest_Bad_NetworkError(t *testing.T) {
|
||||||
assert.False(t, h.IsConnected())
|
assert.False(t, h.IsConnected())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- AutoRegister ----
|
// ---- Task 4: AutoRegister ----
|
||||||
|
|
||||||
func TestAutoRegister_Good(t *testing.T) {
|
func TestAutoRegister_Good(t *testing.T) {
|
||||||
var gotBody map[string]string
|
var gotBody map[string]string
|
||||||
|
|
@ -176,7 +176,7 @@ func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) {
|
||||||
assert.Equal(t, "existing-token", h.config.GetHubToken())
|
assert.Equal(t, "existing-token", h.config.GetHubToken())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Write Operations ----
|
// ---- Task 5: Write Operations ----
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func TestRegister_Good(t *testing.T) {
|
||||||
var gotPath string
|
var gotPath string
|
||||||
|
|
@ -378,3 +378,116 @@ func TestSyncStats_Good(t *testing.T) {
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Len(t, reposRaw, 2)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue