From a3296dd4644017abd00c89aecc72dc572d8786a5 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:32:57 +0000 Subject: [PATCH] feat(bugseti): add hub write operations Add Register, Heartbeat, ClaimIssue, UpdateStatus, ReleaseClaim, and SyncStats methods for hub coordination. ClaimIssue returns ConflictError on 409 and calls drainPendingOps before mutating. Co-Authored-By: Virgil --- internal/bugseti/hub.go | 110 +++++++++++++++++++ internal/bugseti/hub_test.go | 204 +++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go index 09ed242..7573baa 100644 --- a/internal/bugseti/hub.go +++ b/internal/bugseti/hub.go @@ -10,6 +10,8 @@ import ( "io" "log" "net/http" + "net/url" + "runtime" "sync" "time" @@ -287,3 +289,111 @@ func (h *HubService) AutoRegister() error { log.Printf("BugSETI: auto-registered with hub, token cached") return nil } + +// Register registers this client with the hub. +func (h *HubService) Register() error { + h.drainPendingOps() + + name := h.config.GetClientName() + clientID := h.config.GetClientID() + if name == "" { + if len(clientID) >= 8 { + name = "BugSETI-" + clientID[:8] + } else { + name = "BugSETI-" + clientID + } + } + + body := map[string]string{ + "client_id": clientID, + "name": name, + "version": GetVersion(), + "os": runtime.GOOS, + "arch": runtime.GOARCH, + } + + return h.doJSON("POST", "/register", body, nil) +} + +// Heartbeat sends a heartbeat to the hub. +func (h *HubService) Heartbeat() error { + body := map[string]string{ + "client_id": h.config.GetClientID(), + } + return h.doJSON("POST", "/heartbeat", body, nil) +} + +// ClaimIssue claims an issue on the hub, returning the claim details. +// Returns a ConflictError if the issue is already claimed by another client. +func (h *HubService) ClaimIssue(issue *Issue) (*HubClaim, error) { + h.drainPendingOps() + + body := map[string]interface{}{ + "client_id": h.config.GetClientID(), + "issue_id": issue.ID, + "repo": issue.Repo, + "issue_number": issue.Number, + "title": issue.Title, + "url": issue.URL, + } + + var claim HubClaim + if err := h.doJSON("POST", "/issues/claim", body, &claim); err != nil { + return nil, err + } + return &claim, nil +} + +// UpdateStatus updates the status of a claimed issue on the hub. +func (h *HubService) UpdateStatus(issueID, status, prURL string, prNumber int) error { + body := map[string]interface{}{ + "client_id": h.config.GetClientID(), + "status": status, + } + if prURL != "" { + body["pr_url"] = prURL + } + if prNumber > 0 { + body["pr_number"] = prNumber + } + + path := "/issues/" + url.PathEscape(issueID) + "/status" + return h.doJSON("PATCH", path, body, nil) +} + +// ReleaseClaim releases a previously claimed issue back to the pool. +func (h *HubService) ReleaseClaim(issueID string) error { + body := map[string]string{ + "client_id": h.config.GetClientID(), + } + + path := "/issues/" + url.PathEscape(issueID) + "/claim" + return h.doJSON("DELETE", path, body, nil) +} + +// SyncStats uploads local statistics to the hub. +func (h *HubService) SyncStats(stats *Stats) error { + // Build repos_contributed as a flat string slice from the map keys. + repos := make([]string, 0, len(stats.ReposContributed)) + for k := range stats.ReposContributed { + repos = append(repos, k) + } + + 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, + "total_time_minutes": int(stats.TotalTimeSpent.Minutes()), + "repos_contributed": repos, + }, + } + + return h.doJSON("POST", "/stats/sync", body, nil) +} diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go index 80f49d0..263e688 100644 --- a/internal/bugseti/hub_test.go +++ b/internal/bugseti/hub_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -174,3 +175,206 @@ func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) { // Token should remain unchanged. assert.Equal(t, "existing-token", h.config.GetHubToken()) } + +// ---- Write Operations ---- + +func TestRegister_Good(t *testing.T) { + var gotPath string + var gotMethod string + var gotBody map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + cfg.config.ClientName = "MyBugSETI" + h := NewHubService(cfg) + + err := h.Register() + require.NoError(t, err) + assert.Equal(t, "/api/bugseti/register", gotPath) + assert.Equal(t, "POST", gotMethod) + assert.Equal(t, "MyBugSETI", gotBody["name"]) + assert.NotEmpty(t, gotBody["client_id"]) + assert.NotEmpty(t, gotBody["version"]) + assert.NotEmpty(t, gotBody["os"]) + assert.NotEmpty(t, gotBody["arch"]) +} + +func TestHeartbeat_Good(t *testing.T) { + var gotPath string + var gotMethod string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + err := h.Heartbeat() + require.NoError(t, err) + assert.Equal(t, "/api/bugseti/heartbeat", gotPath) + assert.Equal(t, "POST", gotMethod) +} + +func TestClaimIssue_Good(t *testing.T) { + now := time.Now().Truncate(time.Second) + expires := now.Add(30 * time.Minute) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/bugseti/issues/claim", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "issue-42", body["issue_id"]) + assert.Equal(t, "org/repo", body["repo"]) + assert.Equal(t, float64(42), body["issue_number"]) + assert.Equal(t, "Fix the bug", body["title"]) + + w.WriteHeader(http.StatusOK) + resp := HubClaim{ + ID: "claim-1", + IssueURL: "https://github.com/org/repo/issues/42", + ClientID: "test", + ClaimedAt: now, + ExpiresAt: expires, + Status: "claimed", + } + _ = 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) + + issue := &Issue{ + ID: "issue-42", + Number: 42, + Repo: "org/repo", + Title: "Fix the bug", + URL: "https://github.com/org/repo/issues/42", + } + + claim, err := h.ClaimIssue(issue) + require.NoError(t, err) + require.NotNil(t, claim) + assert.Equal(t, "claim-1", claim.ID) + assert.Equal(t, "claimed", claim.Status) +} + +func TestClaimIssue_Bad_Conflict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + issue := &Issue{ID: "issue-99", Number: 99, Repo: "org/repo", Title: "Already claimed"} + + claim, err := h.ClaimIssue(issue) + assert.Nil(t, claim) + require.Error(t, err) + + var conflictErr *ConflictError + assert.ErrorAs(t, err, &conflictErr) +} + +func TestUpdateStatus_Good(t *testing.T) { + var gotPath string + var gotMethod string + var gotBody map[string]interface{} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + err := h.UpdateStatus("issue-42", "completed", "https://github.com/org/repo/pull/10", 10) + require.NoError(t, err) + assert.Equal(t, "PATCH", gotMethod) + assert.Equal(t, "/api/bugseti/issues/issue-42/status", gotPath) + assert.Equal(t, "completed", gotBody["status"]) + assert.Equal(t, "https://github.com/org/repo/pull/10", gotBody["pr_url"]) + assert.Equal(t, float64(10), gotBody["pr_number"]) +} + +func TestSyncStats_Good(t *testing.T) { + var gotBody map[string]interface{} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/bugseti/stats/sync", r.URL.Path) + assert.Equal(t, "POST", r.Method) + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + stats := &Stats{ + IssuesAttempted: 10, + IssuesCompleted: 7, + IssuesSkipped: 3, + PRsSubmitted: 6, + PRsMerged: 5, + PRsRejected: 1, + CurrentStreak: 3, + LongestStreak: 5, + TotalTimeSpent: 90 * time.Minute, + ReposContributed: map[string]*RepoStats{ + "org/repo-a": {Name: "org/repo-a"}, + "org/repo-b": {Name: "org/repo-b"}, + }, + } + + err := h.SyncStats(stats) + require.NoError(t, err) + + assert.NotEmpty(t, gotBody["client_id"]) + statsMap, ok := gotBody["stats"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, float64(10), statsMap["issues_attempted"]) + assert.Equal(t, float64(7), statsMap["issues_completed"]) + assert.Equal(t, float64(3), statsMap["issues_skipped"]) + assert.Equal(t, float64(6), statsMap["prs_submitted"]) + assert.Equal(t, float64(5), statsMap["prs_merged"]) + assert.Equal(t, float64(1), statsMap["prs_rejected"]) + assert.Equal(t, float64(3), statsMap["current_streak"]) + assert.Equal(t, float64(5), statsMap["longest_streak"]) + assert.Equal(t, float64(90), statsMap["total_time_minutes"]) + + reposRaw, ok := statsMap["repos_contributed"].([]interface{}) + require.True(t, ok) + assert.Len(t, reposRaw, 2) +}