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 <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0af6407666
commit
f85bba5332
2 changed files with 177 additions and 0 deletions
129
internal/bugseti/hub.go
Normal file
129
internal/bugseti/hub.go
Normal file
|
|
@ -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() {}
|
||||
48
internal/bugseti/hub_test.go
Normal file
48
internal/bugseti/hub_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue