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
a38ce051b6
commit
a89acfa412
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