cli/internal/bugseti/hub.go
Snider 21d5f5f6df feat(bugseti): add AutoRegister via Forge token exchange
Exchange a Forge API token for a hub API key by POSTing to
/api/bugseti/auth/forge. Skips if hub token already cached.
Adds drainPendingOps() stub for future Task 7 use.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00

289 lines
7.5 KiB
Go

// Package bugseti provides services for the BugSETI distributed bug fixing application.
package bugseti
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
"github.com/host-uk/core/pkg/forge"
)
// 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)
}
// doRequest builds and executes an HTTP request against the hub API.
// It returns the raw *http.Response and any transport-level error.
func (h *HubService) doRequest(method, path string, body interface{}) (*http.Response, error) {
hubURL := h.config.GetHubURL()
if hubURL == "" {
return nil, fmt.Errorf("hub URL not configured")
}
fullURL := hubURL + "/api/bugseti" + path
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, fullURL, bodyReader)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
token := h.config.GetHubToken()
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := h.client.Do(req)
if err != nil {
h.mu.Lock()
h.connected = false
h.mu.Unlock()
return nil, err
}
h.mu.Lock()
h.connected = true
h.mu.Unlock()
return resp, nil
}
// doJSON executes an HTTP request and decodes the JSON response into dest.
// It handles common error status codes with typed errors.
func (h *HubService) doJSON(method, path string, body, dest interface{}) error {
resp, err := h.doRequest(method, path, body)
if err != nil {
return err
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusUnauthorized:
return fmt.Errorf("unauthorised")
case resp.StatusCode == http.StatusConflict:
return &ConflictError{StatusCode: resp.StatusCode}
case resp.StatusCode == http.StatusNotFound:
return &NotFoundError{StatusCode: resp.StatusCode}
case resp.StatusCode >= 400:
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("hub error %d: %s", resp.StatusCode, string(respBody))
}
if dest != nil {
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
// 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() {}
// drainPendingOps replays queued operations (no-op until Task 7).
func (h *HubService) drainPendingOps() {}
// AutoRegister exchanges a Forge API token for a hub API key.
// If a hub token is already configured, this is a no-op.
func (h *HubService) AutoRegister() error {
// Skip if already registered.
if h.config.GetHubToken() != "" {
return nil
}
hubURL := h.config.GetHubURL()
if hubURL == "" {
return fmt.Errorf("hub URL not configured")
}
// Resolve forge credentials from config/env.
forgeURL := h.config.GetForgeURL()
forgeToken := h.config.GetForgeToken()
if forgeToken == "" {
resolvedURL, resolvedToken, err := forge.ResolveConfig(forgeURL, "")
if err != nil {
return fmt.Errorf("resolve forge config: %w", err)
}
forgeURL = resolvedURL
forgeToken = resolvedToken
}
if forgeToken == "" {
return fmt.Errorf("no forge token available (set FORGE_TOKEN or run: core forge config --token TOKEN)")
}
// Build request body.
payload := map[string]string{
"forge_url": forgeURL,
"forge_token": forgeToken,
"client_id": h.config.GetClientID(),
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal auto-register body: %w", err)
}
// POST directly (no bearer token yet).
resp, err := h.client.Post(hubURL+"/api/bugseti/auth/forge", "application/json", bytes.NewReader(data))
if err != nil {
h.mu.Lock()
h.connected = false
h.mu.Unlock()
return fmt.Errorf("auto-register request: %w", err)
}
defer resp.Body.Close()
h.mu.Lock()
h.connected = true
h.mu.Unlock()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("auto-register failed %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
APIKey string `json:"api_key"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode auto-register response: %w", err)
}
if err := h.config.SetHubToken(result.APIKey); err != nil {
return fmt.Errorf("cache hub token: %w", err)
}
log.Printf("BugSETI: auto-registered with hub, token cached")
return nil
}