From 336766d13d61b96f94357a9cc67c4834ebd11ae0 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:24:38 +0000 Subject: [PATCH] feat(bugseti): add HubService types and constructor Introduce HubService struct with types for hub coordination: PendingOp, HubClaim, LeaderboardEntry, GlobalStats, ConflictError, NotFoundError. Constructor generates a crypto/rand client ID when none exists. Includes no-op loadPendingOps/savePendingOps stubs for future persistence. Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- internal/bugseti/hub.go | 129 +++++++++++++++++++++++++++++++++++ internal/bugseti/hub_test.go | 48 +++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 internal/bugseti/hub.go create mode 100644 internal/bugseti/hub_test.go diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go new file mode 100644 index 00000000..79d54cf3 --- /dev/null +++ b/internal/bugseti/hub.go @@ -0,0 +1,129 @@ +// Package bugseti provides services for the BugSETI distributed bug fixing application. +package bugseti + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "sync" + "time" +) + +// HubService coordinates with the agentic portal for issue assignment and leaderboard. +type HubService struct { + config *ConfigService + client *http.Client + connected bool + pending []PendingOp + mu sync.RWMutex +} + +// PendingOp represents an operation queued for retry when the hub is unreachable. +type PendingOp struct { + Method string `json:"method"` + Path string `json:"path"` + Body interface{} `json:"body,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// HubClaim represents a claimed issue from the hub. +type HubClaim struct { + ID string `json:"id"` + IssueURL string `json:"issueUrl"` + ClientID string `json:"clientId"` + ClaimedAt time.Time `json:"claimedAt"` + ExpiresAt time.Time `json:"expiresAt"` + Status string `json:"status"` +} + +// LeaderboardEntry represents a single entry on the leaderboard. +type LeaderboardEntry struct { + ClientID string `json:"clientId"` + ClientName string `json:"clientName"` + Score int `json:"score"` + PRsMerged int `json:"prsMerged"` + Rank int `json:"rank"` +} + +// GlobalStats holds aggregate statistics from the hub. +type GlobalStats struct { + TotalClients int `json:"totalClients"` + TotalClaims int `json:"totalClaims"` + TotalPRsMerged int `json:"totalPrsMerged"` + ActiveClaims int `json:"activeClaims"` + IssuesAvailable int `json:"issuesAvailable"` +} + +// ConflictError indicates a 409 response from the hub (e.g. issue already claimed). +type ConflictError struct { + StatusCode int +} + +func (e *ConflictError) Error() string { + return fmt.Sprintf("conflict: status %d", e.StatusCode) +} + +// NotFoundError indicates a 404 response from the hub. +type NotFoundError struct { + StatusCode int +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("not found: status %d", e.StatusCode) +} + +// NewHubService creates a new HubService with the given config. +// If the config has no ClientID, one is generated and persisted. +func NewHubService(config *ConfigService) *HubService { + h := &HubService{ + config: config, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + pending: make([]PendingOp, 0), + } + + // Generate client ID if not set. + if config.GetClientID() == "" { + id := generateClientID() + _ = config.SetClientID(id) + } + + h.loadPendingOps() + + return h +} + +// ServiceName returns the service name for Wails. +func (h *HubService) ServiceName() string { + return "HubService" +} + +// GetClientID returns the client ID from config. +func (h *HubService) GetClientID() string { + return h.config.GetClientID() +} + +// IsConnected returns whether the hub was reachable on the last request. +func (h *HubService) IsConnected() bool { + h.mu.RLock() + defer h.mu.RUnlock() + return h.connected +} + +// generateClientID creates a random hex string (16 bytes = 32 hex chars). +func generateClientID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Fallback: this should never happen with crypto/rand. + return fmt.Sprintf("fallback-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +// loadPendingOps is a no-op placeholder (disk persistence comes in Task 7). +func (h *HubService) loadPendingOps() {} + +// savePendingOps is a no-op placeholder (disk persistence comes in Task 7). +func (h *HubService) savePendingOps() {} diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go new file mode 100644 index 00000000..b04cb15b --- /dev/null +++ b/internal/bugseti/hub_test.go @@ -0,0 +1,48 @@ +package bugseti + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testHubService(t *testing.T, serverURL string) *HubService { + t.Helper() + cfg := testConfigService(t, nil, nil) + if serverURL != "" { + cfg.config.HubURL = serverURL + } + return NewHubService(cfg) +} + +// ---- NewHubService ---- + +func TestNewHubService_Good(t *testing.T) { + h := testHubService(t, "") + require.NotNil(t, h) + assert.NotNil(t, h.config) + assert.NotNil(t, h.client) + assert.False(t, h.IsConnected()) +} + +func TestHubServiceName_Good(t *testing.T) { + h := testHubService(t, "") + assert.Equal(t, "HubService", h.ServiceName()) +} + +func TestNewHubService_Good_GeneratesClientID(t *testing.T) { + h := testHubService(t, "") + id := h.GetClientID() + assert.NotEmpty(t, id) + // 16 bytes = 32 hex characters + assert.Len(t, id, 32) +} + +func TestNewHubService_Good_ReusesClientID(t *testing.T) { + cfg := testConfigService(t, nil, nil) + cfg.config.ClientID = "existing-client-id" + + h := NewHubService(cfg) + assert.Equal(t, "existing-client-id", h.GetClientID()) +}