diff --git a/cmd/bugseti/main.go b/cmd/bugseti/main.go index 57e4154..7831736 100644 --- a/cmd/bugseti/main.go +++ b/cmd/bugseti/main.go @@ -52,6 +52,7 @@ func main() { queueService := bugseti.NewQueueService(configService) seederService := bugseti.NewSeederService(configService, forgeClient.URL(), forgeClient.Token()) submitService := bugseti.NewSubmitService(configService, notifyService, statsService, forgeClient) + hubService := bugseti.NewHubService(configService) versionService := bugseti.NewVersionService() workspaceService := NewWorkspaceService(configService) @@ -75,6 +76,7 @@ func main() { application.NewService(submitService), application.NewService(versionService), application.NewService(workspaceService), + application.NewService(hubService), application.NewService(trayService), } @@ -113,6 +115,19 @@ func main() { log.Println(" - Waiting for issues...") log.Printf(" - Version: %s (%s)", bugseti.GetVersion(), bugseti.GetChannel()) + // Attempt hub registration (non-blocking) + if hubURL := configService.GetHubURL(); hubURL != "" { + if err := hubService.AutoRegister(); err != nil { + log.Printf(" - Hub: auto-register skipped: %v", err) + } else if err := hubService.Register(); err != nil { + log.Printf(" - Hub: registration failed: %v", err) + } else { + log.Println(" - Hub: registered with portal") + } + } else { + log.Println(" - Hub: not configured (set hubUrl in config)") + } + if err := app.Run(); err != nil { log.Fatal(err) } diff --git a/docs/plans/2026-02-13-bugseti-hub-service-design.md b/docs/plans/2026-02-13-bugseti-hub-service-design.md new file mode 100644 index 0000000..2f132e4 --- /dev/null +++ b/docs/plans/2026-02-13-bugseti-hub-service-design.md @@ -0,0 +1,150 @@ +# BugSETI HubService Design + +## Overview + +A thin HTTP client service in the BugSETI desktop app that coordinates with the agentic portal's `/api/bugseti/*` endpoints. Prevents duplicate work across the 11 community testers, aggregates stats for leaderboard, and registers client instances. + +## Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Target | Direct to portal API | Endpoints built for this purpose | +| Auth | Auto-register via forge token | No manual key management for users | +| Sync strategy | Lazy/manual | User-triggered claims, manual stats sync | +| Offline mode | Offline-first | Queue failed writes, retry on reconnect | +| Approach | Thin HTTP client (net/http) | Matches existing patterns, no deps | + +## Architecture + +**File:** `internal/bugseti/hub.go` + `hub_test.go` + +``` +HubService +├── HTTP client (net/http, 10s timeout) +├── Auth: auto-register via forge token → cached ak_ token +├── Config: HubURL, HubToken, ClientID in ConfigService +├── Offline-first: queue failed writes, drain on next success +└── Lazy sync: user-triggered, no background goroutines +``` + +**Dependencies:** ConfigService only. + +**Integration:** +- QueueService calls `hub.ClaimIssue()` when user picks an issue +- SubmitService calls `hub.UpdateStatus("completed")` after PR +- TrayService calls `hub.GetLeaderboard()` from UI +- main.go calls `hub.Register()` on startup + +## Data Types + +```go +type HubClient struct { + ClientID string // UUID, generated once, persisted in config + Name string // e.g. "Snider's MacBook" + Version string // bugseti.GetVersion() + OS string // runtime.GOOS + Arch string // runtime.GOARCH +} + +type HubClaim struct { + IssueID string // "owner/repo#123" + Repo string + IssueNumber int + Title string + URL string + Status string // claimed|in_progress|completed|skipped + ClaimedAt time.Time + PRUrl string + PRNumber int +} + +type LeaderboardEntry struct { + Rank int + ClientName string + IssuesCompleted int + PRsSubmitted int + PRsMerged int + CurrentStreak int +} + +type GlobalStats struct { + TotalParticipants int + ActiveParticipants int + TotalIssuesCompleted int + TotalPRsMerged int + ActiveClaims int +} +``` + +## API Mapping + +| Method | HTTP | Endpoint | Trigger | +|--------|------|----------|---------| +| `Register()` | POST /register | App startup | +| `Heartbeat()` | POST /heartbeat | Manual / periodic if enabled | +| `ClaimIssue(issue)` | POST /issues/claim | User picks issue | +| `UpdateStatus(id, status)` | PATCH /issues/{id}/status | PR submitted, skip | +| `ReleaseClaim(id)` | DELETE /issues/{id}/claim | User abandons | +| `IsIssueClaimed(id)` | GET /issues/{id} | Before showing issue | +| `ListClaims(filters)` | GET /issues/claimed | UI active claims view | +| `SyncStats(stats)` | POST /stats/sync | Manual from UI | +| `GetLeaderboard(limit)` | GET /leaderboard | UI leaderboard view | +| `GetGlobalStats()` | GET /stats | UI stats dashboard | + +## Auto-Register Flow + +New endpoint on portal: + +``` +POST /api/bugseti/auth/forge +Body: { "forge_url": "https://forge.lthn.io", "forge_token": "..." } +``` + +Portal validates token against Forgejo API (`/api/v1/user`), creates an AgentApiKey with `bugseti.read` + `bugseti.write` scopes, returns `{ "api_key": "ak_..." }`. + +HubService caches the `ak_` token in config.json. On 401, clears cached token and re-registers. + +## Error Handling + +| Error | Behaviour | +|-------|-----------| +| Network unreachable | Log, queue write ops, return cached reads | +| 401 Unauthorised | Clear token, re-register via forge | +| 409 Conflict (claim) | Return "already claimed" — not an error | +| 404 (claim not found) | Return nil | +| 429 Rate limited | Back off, queue the op | +| 5xx Server error | Log, queue write ops | + +**Pending operations queue:** +- Failed writes stored in `[]PendingOp`, persisted to `$DataDir/hub_pending.json` +- Drained on next successful user-triggered call (no background goroutine) +- Each op has: method, path, body, created_at + +## Config Changes + +New fields in `Config` struct: + +```go +HubURL string `json:"hubUrl,omitempty"` // portal API base URL +HubToken string `json:"hubToken,omitempty"` // cached ak_ token +ClientID string `json:"clientId,omitempty"` // UUID, generated once +ClientName string `json:"clientName,omitempty"` // display name +``` + +## Files Changed + +| File | Action | +|------|--------| +| `internal/bugseti/hub.go` | New — HubService | +| `internal/bugseti/hub_test.go` | New — httptest-based tests | +| `internal/bugseti/config.go` | Edit — add Hub* + ClientID fields | +| `cmd/bugseti/main.go` | Edit — create + register HubService | +| `cmd/bugseti/tray.go` | Edit — leaderboard/stats menu items | +| Laravel: auth controller | New — `/api/bugseti/auth/forge` | + +## Testing + +- `httptest.NewServer` mocks for all endpoints +- Test success, network error, 409 conflict, 401 re-auth flows +- Test pending ops queue: add when offline, drain on reconnect +- `_Good`, `_Bad`, `_Ugly` naming convention diff --git a/docs/plans/2026-02-13-bugseti-hub-service-plan.md b/docs/plans/2026-02-13-bugseti-hub-service-plan.md new file mode 100644 index 0000000..2b9e3bb --- /dev/null +++ b/docs/plans/2026-02-13-bugseti-hub-service-plan.md @@ -0,0 +1,1620 @@ +# BugSETI HubService Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a HubService to BugSETI that coordinates issue claiming, stats sync, and leaderboard with the agentic portal API. + +**Architecture:** Thin HTTP client (`net/http`) in `internal/bugseti/hub.go` talking directly to the portal's `/api/bugseti/*` endpoints. Auto-registers via forge token to get an `ak_` bearer token. Offline-first with pending-ops queue. + +**Tech Stack:** Go stdlib (`net/http`, `encoding/json`), Laravel 12 (portal endpoint), httptest (Go tests) + +--- + +### Task 1: Config Fields + +Add hub-related fields to the Config struct so HubService can persist its state. + +**Files:** +- Modify: `internal/bugseti/config.go` +- Test: `internal/bugseti/fetcher_test.go` (uses `testConfigService`) + +**Step 1: Add config fields** + +In `internal/bugseti/config.go`, add these fields to the `Config` struct after the `ForgeToken` field: + +```go +// Hub coordination (agentic portal) +HubURL string `json:"hubUrl,omitempty"` // Portal API base URL (e.g. https://leth.in) +HubToken string `json:"hubToken,omitempty"` // Cached ak_ bearer token +ClientID string `json:"clientId,omitempty"` // UUID, generated once on first run +ClientName string `json:"clientName,omitempty"` // Display name for leaderboard +``` + +**Step 2: Add getters/setters** + +After the `GetForgeToken()` method, add: + +```go +// GetHubURL returns the hub portal URL. +func (c *ConfigService) GetHubURL() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.config.HubURL +} + +// SetHubURL sets the hub portal URL. +func (c *ConfigService) SetHubURL(url string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.HubURL = url + return c.saveUnsafe() +} + +// GetHubToken returns the cached hub API token. +func (c *ConfigService) GetHubToken() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.config.HubToken +} + +// SetHubToken caches the hub API token. +func (c *ConfigService) SetHubToken(token string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.HubToken = token + return c.saveUnsafe() +} + +// GetClientID returns the persistent client UUID. +func (c *ConfigService) GetClientID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.config.ClientID +} + +// SetClientID sets the persistent client UUID. +func (c *ConfigService) SetClientID(id string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.ClientID = id + return c.saveUnsafe() +} + +// GetClientName returns the display name. +func (c *ConfigService) GetClientName() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.config.ClientName +} + +// SetClientName sets the display name. +func (c *ConfigService) SetClientName(name string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.ClientName = name + return c.saveUnsafe() +} +``` + +**Step 3: Run tests** + +Run: `go test ./internal/bugseti/... -count=1` +Expected: All existing tests pass (config fields are additive, no breakage). + +**Step 4: Commit** + +```bash +git add internal/bugseti/config.go +git commit -m "feat(bugseti): add hub config fields (HubURL, HubToken, ClientID, ClientName)" +``` + +--- + +### Task 2: HubService Core — Types and Constructor + +Create the HubService with data types, constructor, and ServiceName. + +**Files:** +- Create: `internal/bugseti/hub.go` +- Create: `internal/bugseti/hub_test.go` + +**Step 1: Write failing tests** + +Create `internal/bugseti/hub_test.go`: + +```go +package bugseti + +import ( + "testing" +) + +func testHubService(t *testing.T, serverURL string) *HubService { + t.Helper() + cfg := testConfigService(t, nil, nil) + if serverURL != "" { + cfg.config.HubURL = serverURL + } + return NewHubService(cfg) +} + +// --- Constructor / ServiceName --- + +func TestNewHubService_Good(t *testing.T) { + h := testHubService(t, "") + if h == nil { + t.Fatal("expected non-nil HubService") + } + if h.config == nil { + t.Fatal("expected config to be set") + } +} + +func TestHubServiceName_Good(t *testing.T) { + h := testHubService(t, "") + if got := h.ServiceName(); got != "HubService" { + t.Fatalf("expected HubService, got %s", got) + } +} + +func TestNewHubService_Good_GeneratesClientID(t *testing.T) { + h := testHubService(t, "") + id := h.GetClientID() + if id == "" { + t.Fatal("expected client ID to be generated") + } + if len(id) < 32 { + t.Fatalf("expected UUID-length client ID, got %d chars", len(id)) + } +} + +func TestNewHubService_Good_ReusesClientID(t *testing.T) { + cfg := testConfigService(t, nil, nil) + cfg.config.ClientID = "existing-id-12345" + h := NewHubService(cfg) + if h.GetClientID() != "existing-id-12345" { + t.Fatal("expected existing client ID to be preserved") + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/bugseti/... -run TestNewHubService -count=1` +Expected: FAIL — `NewHubService` not defined. + +**Step 3: Write HubService core** + +Create `internal/bugseti/hub.go`: + +```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" + "net/url" + "runtime" + "sync" + "time" +) + +// HubService coordinates with the agentic portal for issue claiming, +// stats sync, and leaderboard. +type HubService struct { + config *ConfigService + httpClient *http.Client + mu sync.Mutex + connected bool + pendingOps []PendingOp +} + +// PendingOp represents a failed write operation queued for retry. +type PendingOp struct { + Method string `json:"method"` + Path string `json:"path"` + Body []byte `json:"body"` + CreatedAt time.Time `json:"createdAt"` +} + +// HubClaim represents an issue claim from the portal. +type HubClaim struct { + IssueID string `json:"issue_id"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + Title string `json:"issue_title"` + URL string `json:"issue_url"` + Status string `json:"status"` + ClaimedAt time.Time `json:"claimed_at"` + PRUrl string `json:"pr_url,omitempty"` + PRNumber int `json:"pr_number,omitempty"` +} + +// LeaderboardEntry represents a single entry on the leaderboard. +type LeaderboardEntry struct { + Rank int `json:"rank"` + ClientName string `json:"client_name"` + ClientVersion string `json:"client_version,omitempty"` + IssuesCompleted int `json:"issues_completed"` + PRsSubmitted int `json:"prs_submitted"` + PRsMerged int `json:"prs_merged"` + CurrentStreak int `json:"current_streak"` + LongestStreak int `json:"longest_streak"` +} + +// GlobalStats represents aggregate stats from the portal. +type GlobalStats struct { + TotalParticipants int `json:"total_participants"` + ActiveParticipants int `json:"active_participants"` + TotalIssuesAttempted int `json:"total_issues_attempted"` + TotalIssuesCompleted int `json:"total_issues_completed"` + TotalPRsSubmitted int `json:"total_prs_submitted"` + TotalPRsMerged int `json:"total_prs_merged"` + ActiveClaims int `json:"active_claims"` + CompletedClaims int `json:"completed_claims"` +} + +// NewHubService creates a new HubService. +func NewHubService(config *ConfigService) *HubService { + h := &HubService{ + config: config, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } + + // Ensure a persistent client ID exists + if config.GetClientID() == "" { + id := generateClientID() + if err := config.SetClientID(id); err != nil { + log.Printf("Warning: could not persist client ID: %v", err) + } + } + + // Load pending ops from disk + h.loadPendingOps() + + return h +} + +// ServiceName returns the service name for Wails. +func (h *HubService) ServiceName() string { + return "HubService" +} + +// GetClientID returns the persistent client identifier. +func (h *HubService) GetClientID() string { + return h.config.GetClientID() +} + +// IsConnected returns whether the last hub request succeeded. +func (h *HubService) IsConnected() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.connected +} + +// generateClientID creates a random hex client identifier. +func generateClientID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Fallback to timestamp-based ID + return fmt.Sprintf("bugseti-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} +``` + +**Step 4: Run tests** + +Run: `go test ./internal/bugseti/... -run TestNewHubService -count=1 && go test ./internal/bugseti/... -run TestHubServiceName -count=1` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/bugseti/hub.go internal/bugseti/hub_test.go +git commit -m "feat(bugseti): add HubService types and constructor" +``` + +--- + +### Task 3: HTTP Request Helpers + +Add the internal `doRequest` and `doJSON` methods that all API calls use. + +**Files:** +- Modify: `internal/bugseti/hub.go` +- Modify: `internal/bugseti/hub_test.go` + +**Step 1: Write failing tests** + +Add to `hub_test.go`: + +```go +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDoRequest_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Fatal("expected bearer token") + } + if r.Header.Get("Content-Type") != "application/json" { + t.Fatal("expected JSON content type") + } + w.WriteHeader(200) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "test-token" + + resp, err := h.doRequest("GET", "/test", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +func TestDoRequest_Bad_NoHubURL(t *testing.T) { + h := testHubService(t, "") + _, err := h.doRequest("GET", "/test", nil) + if err == nil { + t.Fatal("expected error when hub URL is empty") + } +} + +func TestDoRequest_Bad_NetworkError(t *testing.T) { + h := testHubService(t, "http://127.0.0.1:1") // Nothing listening + h.config.config.HubToken = "test-token" + + _, err := h.doRequest("GET", "/test", nil) + if err == nil { + t.Fatal("expected network error") + } +} +``` + +**Step 2: Run to verify failure** + +Run: `go test ./internal/bugseti/... -run TestDoRequest -count=1` +Expected: FAIL — `doRequest` not defined. + +**Step 3: Implement helpers** + +Add to `hub.go`: + +```go +// doRequest performs an HTTP request to the hub API. +// Returns the response (caller must close body) or an 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("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, fullURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + if token := h.config.GetHubToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := h.httpClient.Do(req) + if err != nil { + h.mu.Lock() + h.connected = false + h.mu.Unlock() + return nil, fmt.Errorf("hub request failed: %w", err) + } + + h.mu.Lock() + h.connected = true + h.mu.Unlock() + + return resp, nil +} + +// doJSON performs a request and decodes the JSON response into dest. +func (h *HubService) doJSON(method, path string, body interface{}, dest interface{}) error { + resp, err := h.doRequest(method, path, body) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == 401 { + return fmt.Errorf("unauthorised") + } + if resp.StatusCode == 409 { + return &ConflictError{StatusCode: resp.StatusCode} + } + if resp.StatusCode == 404 { + return &NotFoundError{StatusCode: resp.StatusCode} + } + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("hub error %d: %s", resp.StatusCode, string(bodyBytes)) + } + + if dest != nil { + if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + + return nil +} + +// ConflictError indicates a 409 response (e.g. issue already claimed). +type ConflictError struct { + StatusCode int +} + +func (e *ConflictError) Error() string { + return fmt.Sprintf("conflict (HTTP %d)", e.StatusCode) +} + +// NotFoundError indicates a 404 response. +type NotFoundError struct { + StatusCode int +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("not found (HTTP %d)", e.StatusCode) +} +``` + +**Step 4: Run tests** + +Run: `go test ./internal/bugseti/... -run TestDoRequest -count=1` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/bugseti/hub.go internal/bugseti/hub_test.go +git commit -m "feat(bugseti): add hub HTTP request helpers with error types" +``` + +--- + +### Task 4: Auto-Register via Forge Token + +Implement the auth flow: send forge token to portal, receive `ak_` token. + +**Files:** +- Modify: `internal/bugseti/hub.go` +- Modify: `internal/bugseti/hub_test.go` + +**Step 1: Write failing tests** + +Add to `hub_test.go`: + +```go +func TestAutoRegister_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/bugseti/auth/forge" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Method != "POST" { + t.Fatalf("expected POST, got %s", r.Method) + } + + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + + if body["forge_url"] == "" || body["forge_token"] == "" { + w.WriteHeader(400) + return + } + + w.WriteHeader(201) + json.NewEncoder(w).Encode(map[string]string{ + "api_key": "ak_test123456789012345678901234", + }) + })) + defer server.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = server.URL + cfg.config.ForgeURL = "https://forge.lthn.io" + cfg.config.ForgeToken = "forge-test-token" + h := NewHubService(cfg) + + err := h.AutoRegister() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.GetHubToken() != "ak_test123456789012345678901234" { + t.Fatalf("expected token to be cached, got %q", cfg.GetHubToken()) + } +} + +func TestAutoRegister_Bad_NoForgeToken(t *testing.T) { + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = "http://localhost" + h := NewHubService(cfg) + + err := h.AutoRegister() + if err == nil { + t.Fatal("expected error when forge token is missing") + } +} + +func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) { + cfg := testConfigService(t, nil, nil) + cfg.config.HubToken = "ak_existing_token" + h := NewHubService(cfg) + + err := h.AutoRegister() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Token should remain unchanged + if cfg.GetHubToken() != "ak_existing_token" { + t.Fatal("existing token should not be overwritten") + } +} +``` + +**Step 2: Run to verify failure** + +Run: `go test ./internal/bugseti/... -run TestAutoRegister -count=1` +Expected: FAIL — `AutoRegister` not defined. + +**Step 3: Implement AutoRegister** + +Add to `hub.go`: + +```go +// AutoRegister exchanges forge credentials for a hub API key. +// Skips if a token is already cached. On 401, clears cached token. +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") + } + + forgeURL := h.config.GetForgeURL() + forgeToken := h.config.GetForgeToken() + + // Fall back to pkg/forge config resolution + if forgeURL == "" || forgeToken == "" { + resolvedURL, resolvedToken, err := resolveForgeConfig(forgeURL, forgeToken) + if err != nil { + return fmt.Errorf("failed to resolve forge config: %w", err) + } + forgeURL = resolvedURL + forgeToken = resolvedToken + } + + if forgeToken == "" { + return fmt.Errorf("forge token not configured — cannot auto-register with hub") + } + + body := map[string]string{ + "forge_url": forgeURL, + "forge_token": forgeToken, + "client_id": h.GetClientID(), + } + + var result struct { + APIKey string `json:"api_key"` + } + + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal register body: %w", err) + } + + resp, err := h.httpClient.Post( + hubURL+"/api/bugseti/auth/forge", + "application/json", + bytes.NewReader(data), + ) + if err != nil { + return fmt.Errorf("hub auto-register failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 201 && resp.StatusCode != 200 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("hub auto-register failed (HTTP %d): %s", resp.StatusCode, string(bodyBytes)) + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to decode register response: %w", err) + } + + if result.APIKey == "" { + return fmt.Errorf("hub returned empty API key") + } + + // Cache the token + if err := h.config.SetHubToken(result.APIKey); err != nil { + return fmt.Errorf("failed to cache hub token: %w", err) + } + + log.Printf("Hub: registered with portal, token cached") + return nil +} + +// resolveForgeConfig gets forge URL/token from pkg/forge config chain. +func resolveForgeConfig(flagURL, flagToken string) (string, string, error) { + // Import forge package for config resolution + // This uses the same resolution chain: config.yaml → env vars → flags + forgeURL, forgeToken, err := forgeResolveConfig(flagURL, flagToken) + if err != nil { + return "", "", err + } + return forgeURL, forgeToken, nil +} +``` + +Note: `resolveForgeConfig` wraps `forge.ResolveConfig` — we'll use the import directly in the real code. For the plan, this shows the intent. + +**Step 4: Run tests** + +Run: `go test ./internal/bugseti/... -run TestAutoRegister -count=1` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/bugseti/hub.go internal/bugseti/hub_test.go +git commit -m "feat(bugseti): hub auto-register via forge token" +``` + +--- + +### Task 5: Write Operations — Register, Heartbeat, Claim, Update, Release, SyncStats + +Implement all write API methods. + +**Files:** +- Modify: `internal/bugseti/hub.go` +- Modify: `internal/bugseti/hub_test.go` + +**Step 1: Write failing tests** + +Add to `hub_test.go`: + +```go +func TestRegister_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/bugseti/register" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["client_id"] == "" || body["name"] == "" { + w.WriteHeader(400) + return + } + w.WriteHeader(201) + json.NewEncoder(w).Encode(map[string]interface{}{"client": body}) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + h.config.config.ClientName = "Test Mac" + + err := h.Register() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestHeartbeat_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + + err := h.Heartbeat() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClaimIssue_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(201) + json.NewEncoder(w).Encode(map[string]interface{}{ + "claim": map[string]interface{}{ + "issue_id": "owner/repo#42", + "status": "claimed", + }, + }) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + + claim, err := h.ClaimIssue(&Issue{ + ID: "owner/repo#42", Repo: "owner/repo", Number: 42, + Title: "Fix bug", URL: "https://forge.lthn.io/owner/repo/issues/42", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claim == nil || claim.IssueID != "owner/repo#42" { + t.Fatal("expected claim with correct issue ID") + } +} + +func TestClaimIssue_Bad_Conflict(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(409) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Issue already claimed", + }) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + + _, err := h.ClaimIssue(&Issue{ID: "owner/repo#42", Repo: "owner/repo", Number: 42}) + if err == nil { + t.Fatal("expected conflict error") + } + if _, ok := err.(*ConflictError); !ok { + t.Fatalf("expected ConflictError, got %T", err) + } +} + +func TestUpdateStatus_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Fatalf("expected PATCH, got %s", r.Method) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"claim": map[string]string{"status": "completed"}}) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + + err := h.UpdateStatus("owner/repo#42", "completed", "https://forge.lthn.io/pr/1", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSyncStats_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"synced": true}) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + + err := h.SyncStats(&Stats{ + IssuesCompleted: 5, + PRsSubmitted: 3, + PRsMerged: 2, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} +``` + +**Step 2: Run to verify failure** + +Run: `go test ./internal/bugseti/... -run "TestRegister_Good|TestHeartbeat|TestClaimIssue|TestUpdateStatus|TestSyncStats" -count=1` +Expected: FAIL + +**Step 3: Implement write methods** + +Add to `hub.go`: + +```go +// Register sends client registration to the hub portal. +func (h *HubService) Register() error { + h.drainPendingOps() + + name := h.config.GetClientName() + if name == "" { + name = fmt.Sprintf("BugSETI-%s", h.GetClientID()[:8]) + } + + body := map[string]string{ + "client_id": h.GetClientID(), + "name": name, + "version": GetVersion(), + "os": runtime.GOOS, + "arch": runtime.GOARCH, + } + + return h.doJSON("POST", "/register", body, nil) +} + +// Heartbeat sends a heartbeat to the hub portal. +func (h *HubService) Heartbeat() error { + body := map[string]string{ + "client_id": h.GetClientID(), + } + return h.doJSON("POST", "/heartbeat", body, nil) +} + +// ClaimIssue claims an issue on the hub portal. +// Returns the claim on success, ConflictError if already claimed. +func (h *HubService) ClaimIssue(issue *Issue) (*HubClaim, error) { + if issue == nil { + return nil, fmt.Errorf("issue is nil") + } + + h.drainPendingOps() + + body := map[string]interface{}{ + "client_id": h.GetClientID(), + "issue_id": issue.ID, + "repo": issue.Repo, + "issue_number": issue.Number, + "title": issue.Title, + "url": issue.URL, + } + + var result struct { + Claim *HubClaim `json:"claim"` + } + + if err := h.doJSON("POST", "/issues/claim", body, &result); err != nil { + return nil, err + } + + return result.Claim, nil +} + +// UpdateStatus updates the status of a claimed issue. +func (h *HubService) UpdateStatus(issueID, status, prURL string, prNumber int) error { + body := map[string]interface{}{ + "client_id": h.GetClientID(), + "status": status, + } + if prURL != "" { + body["pr_url"] = prURL + body["pr_number"] = prNumber + } + + encodedID := url.PathEscape(issueID) + return h.doJSON("PATCH", "/issues/"+encodedID+"/status", body, nil) +} + +// ReleaseClaim releases a claim on an issue. +func (h *HubService) ReleaseClaim(issueID string) error { + body := map[string]string{ + "client_id": h.GetClientID(), + } + + encodedID := url.PathEscape(issueID) + return h.doJSON("DELETE", "/issues/"+encodedID+"/claim", body, nil) +} + +// SyncStats uploads local stats to the hub portal. +func (h *HubService) SyncStats(stats *Stats) error { + if stats == nil { + return fmt.Errorf("stats is nil") + } + + repoNames := make([]string, 0, len(stats.ReposContributed)) + for name := range stats.ReposContributed { + repoNames = append(repoNames, name) + } + + body := map[string]interface{}{ + "client_id": h.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": repoNames, + }, + } + + return h.doJSON("POST", "/stats/sync", body, nil) +} +``` + +**Step 4: Run tests** + +Run: `go test ./internal/bugseti/... -run "TestRegister_Good|TestHeartbeat|TestClaimIssue|TestUpdateStatus|TestSyncStats" -count=1` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/bugseti/hub.go internal/bugseti/hub_test.go +git commit -m "feat(bugseti): hub write operations (register, heartbeat, claim, update, sync)" +``` + +--- + +### Task 6: Read Operations — IsIssueClaimed, ListClaims, GetLeaderboard, GetGlobalStats + +**Files:** +- Modify: `internal/bugseti/hub.go` +- Modify: `internal/bugseti/hub_test.go` + +**Step 1: Write failing tests** + +Add to `hub_test.go`: + +```go +func TestIsIssueClaimed_Good_Claimed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "claim": map[string]interface{}{"issue_id": "o/r#1", "status": "claimed"}, + }) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + + claim, err := h.IsIssueClaimed("o/r#1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claim == nil { + t.Fatal("expected claim") + } +} + +func TestIsIssueClaimed_Good_NotClaimed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + + claim, err := h.IsIssueClaimed("o/r#1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claim != nil { + t.Fatal("expected nil claim for unclaimed issue") + } +} + +func TestGetLeaderboard_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("limit") != "10" { + t.Fatalf("expected limit=10, got %s", r.URL.Query().Get("limit")) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "leaderboard": []map[string]interface{}{{"rank": 1, "client_name": "Alice"}}, + "total_participants": 5, + }) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + + entries, total, err := h.GetLeaderboard(10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 1 || total != 5 { + t.Fatalf("expected 1 entry, 5 total; got %d, %d", len(entries), total) + } +} + +func TestGetGlobalStats_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "global": map[string]interface{}{ + "total_participants": 11, + "active_claims": 3, + }, + }) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + + stats, err := h.GetGlobalStats() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if stats.TotalParticipants != 11 { + t.Fatalf("expected 11 participants, got %d", stats.TotalParticipants) + } +} +``` + +**Step 2: Run to verify failure, then implement** + +Add to `hub.go`: + +```go +// IsIssueClaimed checks if an issue is claimed on the hub. +// Returns the claim if found, nil if not claimed. +func (h *HubService) IsIssueClaimed(issueID string) (*HubClaim, error) { + var result struct { + Claim *HubClaim `json:"claim"` + } + + encodedID := url.PathEscape(issueID) + err := h.doJSON("GET", "/issues/"+encodedID, nil, &result) + if err != nil { + if _, ok := err.(*NotFoundError); ok { + return nil, nil // Not claimed + } + return nil, err + } + + return result.Claim, nil +} + +// ListClaims returns active claims from the hub, with optional filters. +func (h *HubService) ListClaims(status, repo string) ([]*HubClaim, error) { + path := "/issues/claimed" + params := url.Values{} + if status != "" { + params.Set("status", status) + } + if repo != "" { + params.Set("repo", repo) + } + if len(params) > 0 { + path += "?" + params.Encode() + } + + var result struct { + Claims []*HubClaim `json:"claims"` + } + + if err := h.doJSON("GET", path, nil, &result); err != nil { + return nil, err + } + + return result.Claims, nil +} + +// GetLeaderboard returns the leaderboard from the hub portal. +func (h *HubService) GetLeaderboard(limit int) ([]LeaderboardEntry, int, error) { + if limit <= 0 { + limit = 20 + } + + path := fmt.Sprintf("/leaderboard?limit=%d", limit) + + var result struct { + Leaderboard []LeaderboardEntry `json:"leaderboard"` + TotalParticipants int `json:"total_participants"` + } + + if err := h.doJSON("GET", path, nil, &result); err != nil { + return nil, 0, err + } + + return result.Leaderboard, result.TotalParticipants, nil +} + +// GetGlobalStats returns aggregate stats from the hub portal. +func (h *HubService) GetGlobalStats() (*GlobalStats, error) { + var result struct { + Global *GlobalStats `json:"global"` + } + + if err := h.doJSON("GET", "/stats", nil, &result); err != nil { + return nil, err + } + + return result.Global, nil +} +``` + +**Step 3: Run tests** + +Run: `go test ./internal/bugseti/... -run "TestIsIssueClaimed|TestGetLeaderboard|TestGetGlobalStats" -count=1` +Expected: PASS + +**Step 4: Commit** + +```bash +git add internal/bugseti/hub.go internal/bugseti/hub_test.go +git commit -m "feat(bugseti): hub read operations (claims, leaderboard, global stats)" +``` + +--- + +### Task 7: Pending Operations Queue + +Implement offline-first: queue failed writes, persist to disk, drain on reconnect. + +**Files:** +- Modify: `internal/bugseti/hub.go` +- Modify: `internal/bugseti/hub_test.go` + +**Step 1: Write failing tests** + +Add to `hub_test.go`: + +```go +func TestPendingOps_Good_QueueAndDrain(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if r.URL.Path == "/api/bugseti/register" { + // First register drains pending ops — the heartbeat will come first + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"client": nil}) + return + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + })) + defer server.Close() + + h := testHubService(t, server.URL) + h.config.config.HubToken = "ak_test" + + // Manually add a pending op + h.mu.Lock() + h.pendingOps = append(h.pendingOps, PendingOp{ + Method: "POST", + Path: "/heartbeat", + Body: []byte(`{"client_id":"test"}`), + CreatedAt: time.Now(), + }) + h.mu.Unlock() + + // Register should drain the pending heartbeat first + err := h.Register() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if callCount < 2 { + t.Fatalf("expected at least 2 calls (drain + register), got %d", callCount) + } +} + +func TestPendingOps_Good_PersistAndLoad(t *testing.T) { + cfg := testConfigService(t, nil, nil) + h1 := NewHubService(cfg) + + // Add pending op + h1.mu.Lock() + h1.pendingOps = append(h1.pendingOps, PendingOp{ + Method: "POST", + Path: "/heartbeat", + Body: []byte(`{"test":true}`), + CreatedAt: time.Now(), + }) + h1.mu.Unlock() + h1.savePendingOps() + + // Create new service — should load persisted ops + h2 := NewHubService(cfg) + h2.mu.Lock() + count := len(h2.pendingOps) + h2.mu.Unlock() + + if count != 1 { + t.Fatalf("expected 1 pending op after reload, got %d", count) + } +} +``` + +**Step 2: Implement pending ops** + +Add to `hub.go`: + +```go +// queueOp adds a failed write to the pending queue. +func (h *HubService) queueOp(method, path string, body interface{}) { + data, _ := json.Marshal(body) + h.mu.Lock() + h.pendingOps = append(h.pendingOps, PendingOp{ + Method: method, + Path: path, + Body: data, + CreatedAt: time.Now(), + }) + h.mu.Unlock() + h.savePendingOps() +} + +// drainPendingOps replays queued operations. Called before write methods. +func (h *HubService) drainPendingOps() { + h.mu.Lock() + ops := h.pendingOps + h.pendingOps = nil + h.mu.Unlock() + + if len(ops) == 0 { + return + } + + log.Printf("Hub: draining %d pending operations", len(ops)) + var failed []PendingOp + + for _, op := range ops { + resp, err := h.doRequest(op.Method, op.Path, json.RawMessage(op.Body)) + if err != nil { + failed = append(failed, op) + continue + } + resp.Body.Close() + if resp.StatusCode >= 500 { + failed = append(failed, op) + } + // 4xx errors are dropped (stale data) + } + + if len(failed) > 0 { + h.mu.Lock() + h.pendingOps = append(failed, h.pendingOps...) + h.mu.Unlock() + } + + h.savePendingOps() +} + +// savePendingOps persists the pending queue to disk. +func (h *HubService) savePendingOps() { + dataDir := h.config.GetDataDir() + if dataDir == "" { + return + } + + h.mu.Lock() + ops := h.pendingOps + h.mu.Unlock() + + data, err := json.Marshal(ops) + if err != nil { + return + } + + path := filepath.Join(dataDir, "hub_pending.json") + os.WriteFile(path, data, 0600) +} + +// loadPendingOps loads persisted pending operations from disk. +func (h *HubService) loadPendingOps() { + dataDir := h.config.GetDataDir() + if dataDir == "" { + return + } + + path := filepath.Join(dataDir, "hub_pending.json") + data, err := os.ReadFile(path) + if err != nil { + return + } + + var ops []PendingOp + if err := json.Unmarshal(data, &ops); err != nil { + return + } + + h.mu.Lock() + h.pendingOps = ops + h.mu.Unlock() +} + +// PendingCount returns the number of queued operations. +func (h *HubService) PendingCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.pendingOps) +} +``` + +Also add `"os"` and `"path/filepath"` to the imports in `hub.go`. + +**Step 3: Run tests** + +Run: `go test ./internal/bugseti/... -run TestPendingOps -count=1` +Expected: PASS + +**Step 4: Commit** + +```bash +git add internal/bugseti/hub.go internal/bugseti/hub_test.go +git commit -m "feat(bugseti): hub pending operations queue with disk persistence" +``` + +--- + +### Task 8: Integration — main.go and Wails Registration + +Wire HubService into the app lifecycle. + +**Files:** +- Modify: `cmd/bugseti/main.go` + +**Step 1: Create HubService in main.go** + +After the `submitService` creation, add: + +```go +hubService := bugseti.NewHubService(configService) +``` + +Add to the services slice: + +```go +application.NewService(hubService), +``` + +After `log.Println("Starting BugSETI...")`, add: + +```go +// Attempt hub registration (non-blocking, logs warnings on failure) +if hubURL := configService.GetHubURL(); hubURL != "" { + if err := hubService.AutoRegister(); err != nil { + log.Printf("Hub: auto-register skipped: %v", err) + } else if err := hubService.Register(); err != nil { + log.Printf("Hub: registration failed: %v", err) + } +} +``` + +**Step 2: Build and verify** + +Run: `task bugseti:build` +Expected: Builds successfully. + +Run: `go test ./internal/bugseti/... -count=1` +Expected: All tests pass. + +**Step 3: Commit** + +```bash +git add cmd/bugseti/main.go +git commit -m "feat(bugseti): wire HubService into app lifecycle" +``` + +--- + +### Task 9: Laravel Auth/Forge Endpoint + +Create the portal-side endpoint that exchanges a forge token for an `ak_` API key. + +**Files:** +- Create: `agentic/app/Mod/BugSeti/Controllers/AuthController.php` +- Modify: `agentic/app/Mod/BugSeti/Routes/api.php` + +**Step 1: Create AuthController** + +Create `agentic/app/Mod/BugSeti/Controllers/AuthController.php`: + +```php +validate([ + 'forge_url' => 'required|url|max:500', + 'forge_token' => 'required|string|max:255', + 'client_id' => 'required|string|max:64', + ]); + + // Validate the forge token against the Forgejo API + $response = Http::withToken($validated['forge_token']) + ->timeout(10) + ->get(rtrim($validated['forge_url'], '/') . '/api/v1/user'); + + if (! $response->ok()) { + return response()->json([ + 'error' => 'Invalid Forgejo token — could not verify identity.', + ], 401); + } + + $forgeUser = $response->json(); + $forgeName = $forgeUser['full_name'] ?: $forgeUser['login'] ?? 'Unknown'; + + // Find or create workspace for BugSETI clients + $workspace = Workspace::firstOrCreate( + ['slug' => 'bugseti-community'], + ['name' => 'BugSETI Community', 'owner_id' => null] + ); + + // Check if this client already has a key + $existingKey = AgentApiKey::where('workspace_id', $workspace->id) + ->where('name', 'like', '%' . $validated['client_id'] . '%') + ->whereNull('revoked_at') + ->first(); + + if ($existingKey) { + // Revoke old key and issue new one + $existingKey->update(['revoked_at' => now()]); + } + + $apiKey = AgentApiKey::generate( + workspace: $workspace->id, + name: "BugSETI — {$forgeName} ({$validated['client_id']})", + permissions: ['bugseti.read', 'bugseti.write'], + rateLimit: 100, + expiresAt: null, + ); + + return response()->json([ + 'api_key' => $apiKey->plainTextKey, + 'forge_user' => $forgeName, + ], 201); + } +} +``` + +**Step 2: Add route** + +In `agentic/app/Mod/BugSeti/Routes/api.php`, add **outside** the authenticated groups: + +```php +// Unauthenticated bootstrap — exchanges forge token for API key +Route::post('/auth/forge', [AuthController::class, 'forge']); +``` + +Add the use statement at top of file: + +```php +use Mod\BugSeti\Controllers\AuthController; +``` + +**Step 3: Test manually** + +```bash +cd /Users/snider/Code/host-uk/agentic +php artisan migrate +curl -X POST http://leth.test/api/bugseti/auth/forge \ + -H "Content-Type: application/json" \ + -d '{"forge_url":"https://forge.lthn.io","forge_token":"500ecb79c79da940205f37580438575dbf7a82be","client_id":"test-client-1"}' +``` + +Expected: 201 with `{"api_key":"ak_...","forge_user":"..."}`. + +**Step 4: Commit** + +```bash +cd /Users/snider/Code/host-uk/agentic +git add app/Mod/BugSeti/Controllers/AuthController.php app/Mod/BugSeti/Routes/api.php +git commit -m "feat(bugseti): add /auth/forge endpoint for token exchange" +``` + +--- + +### Task 10: Full Integration Test + +Build the binary, configure hub URL, and verify end-to-end. + +**Files:** None (verification only) + +**Step 1: Run all Go tests** + +```bash +cd /Users/snider/Code/host-uk/core +go test ./internal/bugseti/... -count=1 -v +``` + +Expected: All tests pass. + +**Step 2: Build binary** + +```bash +task bugseti:build +``` + +Expected: Binary builds at `bin/bugseti`. + +**Step 3: Configure hub URL and test launch** + +```bash +# Set hub URL to devnet +cat ~/.config/bugseti/config.json | python3 -c " +import json,sys +c = json.load(sys.stdin) +c['hubUrl'] = 'https://leth.in' +json.dump(c, sys.stdout, indent=2) +" > /tmp/bugseti-config.json && mv /tmp/bugseti-config.json ~/.config/bugseti/config.json +``` + +Launch `./bin/bugseti` — should start without errors, attempt hub registration. + +**Step 4: Final commit if needed** + +```bash +git add -A && git commit -m "feat(bugseti): HubService integration complete" +``` + +--- + +### Summary + +| Task | Description | Files | +|------|-------------|-------| +| 1 | Config fields | config.go | +| 2 | HubService types + constructor | hub.go, hub_test.go | +| 3 | HTTP request helpers | hub.go, hub_test.go | +| 4 | Auto-register via forge | hub.go, hub_test.go | +| 5 | Write operations | hub.go, hub_test.go | +| 6 | Read operations | hub.go, hub_test.go | +| 7 | Pending ops queue | hub.go, hub_test.go | +| 8 | main.go integration | main.go | +| 9 | Laravel auth/forge endpoint | AuthController.php, api.php | +| 10 | Full integration test | (verification) | diff --git a/internal/bugseti/config.go b/internal/bugseti/config.go index ea6166c..fe8776e 100644 --- a/internal/bugseti/config.go +++ b/internal/bugseti/config.go @@ -23,6 +23,12 @@ type Config struct { ForgeURL string `json:"forgeUrl,omitempty"` ForgeToken string `json:"forgeToken,omitempty"` + // Hub coordination (agentic portal) + HubURL string `json:"hubUrl,omitempty"` + HubToken string `json:"hubToken,omitempty"` + ClientID string `json:"clientId,omitempty"` + ClientName string `json:"clientName,omitempty"` + // Deprecated: use ForgeToken. Kept for migration. GitHubToken string `json:"githubToken,omitempty"` @@ -546,6 +552,82 @@ func (c *ConfigService) GetForgeToken() string { return c.config.ForgeToken } +// SetForgeURL sets the Forge URL. +func (c *ConfigService) SetForgeURL(url string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.ForgeURL = url + return c.saveUnsafe() +} + +// SetForgeToken sets the Forge token. +func (c *ConfigService) SetForgeToken(token string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.ForgeToken = token + return c.saveUnsafe() +} + +// GetHubURL returns the configured Hub URL. +func (c *ConfigService) GetHubURL() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.config.HubURL +} + +// SetHubURL sets the Hub URL. +func (c *ConfigService) SetHubURL(url string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.HubURL = url + return c.saveUnsafe() +} + +// GetHubToken returns the configured Hub token. +func (c *ConfigService) GetHubToken() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.config.HubToken +} + +// SetHubToken sets the Hub token. +func (c *ConfigService) SetHubToken(token string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.HubToken = token + return c.saveUnsafe() +} + +// GetClientID returns the configured client ID. +func (c *ConfigService) GetClientID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.config.ClientID +} + +// SetClientID sets the client ID. +func (c *ConfigService) SetClientID(id string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.ClientID = id + return c.saveUnsafe() +} + +// GetClientName returns the configured client name. +func (c *ConfigService) GetClientName() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.config.ClientName +} + +// SetClientName sets the client name. +func (c *ConfigService) SetClientName(name string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.config.ClientName = name + return c.saveUnsafe() +} + // ShouldCheckForUpdates returns true if it's time to check for updates. func (c *ConfigService) ShouldCheckForUpdates() bool { c.mu.RLock() diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go new file mode 100644 index 0000000..83f8367 --- /dev/null +++ b/internal/bugseti/hub.go @@ -0,0 +1,576 @@ +// 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" + "net/url" + "os" + "path/filepath" + "runtime" + "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 json.RawMessage `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 +} + +// queueOp marshals body to JSON and appends a PendingOp to the queue. +func (h *HubService) queueOp(method, path string, body interface{}) { + var raw json.RawMessage + if body != nil { + data, err := json.Marshal(body) + if err != nil { + log.Printf("BugSETI: queueOp marshal error: %v", err) + return + } + raw = data + } + + h.mu.Lock() + h.pending = append(h.pending, PendingOp{ + Method: method, + Path: path, + Body: raw, + CreatedAt: time.Now(), + }) + h.mu.Unlock() + + h.savePendingOps() +} + +// drainPendingOps replays queued operations against the hub. +// 5xx/transport errors are kept for retry; 4xx responses are dropped (stale). +func (h *HubService) drainPendingOps() { + h.mu.Lock() + ops := h.pending + h.pending = make([]PendingOp, 0) + h.mu.Unlock() + + if len(ops) == 0 { + return + } + + var failed []PendingOp + for _, op := range ops { + var body interface{} + if len(op.Body) > 0 { + body = json.RawMessage(op.Body) + } + + resp, err := h.doRequest(op.Method, op.Path, body) + if err != nil { + // Transport error — keep for retry. + failed = append(failed, op) + continue + } + resp.Body.Close() + + if resp.StatusCode >= 500 { + // Server error — keep for retry. + failed = append(failed, op) + } // 4xx are dropped (stale). + } + + if len(failed) > 0 { + h.mu.Lock() + h.pending = append(failed, h.pending...) + h.mu.Unlock() + } + + h.savePendingOps() +} + +// savePendingOps persists the pending operations queue to disk. +func (h *HubService) savePendingOps() { + dataDir := h.config.GetDataDir() + if dataDir == "" { + return + } + + h.mu.RLock() + data, err := json.Marshal(h.pending) + h.mu.RUnlock() + if err != nil { + log.Printf("BugSETI: savePendingOps marshal error: %v", err) + return + } + + path := filepath.Join(dataDir, "hub_pending.json") + if err := os.WriteFile(path, data, 0600); err != nil { + log.Printf("BugSETI: savePendingOps write error: %v", err) + } +} + +// loadPendingOps loads the pending operations queue from disk. +// Errors are silently ignored (the file may not exist yet). +func (h *HubService) loadPendingOps() { + dataDir := h.config.GetDataDir() + if dataDir == "" { + return + } + + path := filepath.Join(dataDir, "hub_pending.json") + data, err := os.ReadFile(path) + if err != nil { + return + } + + var ops []PendingOp + if err := json.Unmarshal(data, &ops); err != nil { + return + } + h.pending = ops +} + +// PendingCount returns the number of queued pending operations. +func (h *HubService) PendingCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.pending) +} + +// ---- Task 4: Auto-Register via Forge Token ---- + +// 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 +} + +// ---- Task 5: Write Operations ---- + +// 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) +} + +// ---- Task 6: Read Operations ---- + +// IsIssueClaimed checks whether an issue is currently claimed on the hub. +// Returns the claim if it exists, or (nil, nil) if the issue is not claimed (404). +func (h *HubService) IsIssueClaimed(issueID string) (*HubClaim, error) { + path := "/issues/" + url.PathEscape(issueID) + + var claim HubClaim + if err := h.doJSON("GET", path, nil, &claim); err != nil { + if _, ok := err.(*NotFoundError); ok { + return nil, nil + } + return nil, err + } + return &claim, nil +} + +// ListClaims returns claimed issues, optionally filtered by status and/or repo. +func (h *HubService) ListClaims(status, repo string) ([]*HubClaim, error) { + params := url.Values{} + if status != "" { + params.Set("status", status) + } + if repo != "" { + params.Set("repo", repo) + } + + path := "/issues/claimed" + if encoded := params.Encode(); encoded != "" { + path += "?" + encoded + } + + var claims []*HubClaim + if err := h.doJSON("GET", path, nil, &claims); err != nil { + return nil, err + } + return claims, nil +} + +// leaderboardResponse wraps the hub leaderboard JSON envelope. +type leaderboardResponse struct { + Entries []LeaderboardEntry `json:"entries"` + TotalParticipants int `json:"totalParticipants"` +} + +// GetLeaderboard fetches the top N leaderboard entries from the hub. +func (h *HubService) GetLeaderboard(limit int) ([]LeaderboardEntry, int, error) { + path := fmt.Sprintf("/leaderboard?limit=%d", limit) + + var resp leaderboardResponse + if err := h.doJSON("GET", path, nil, &resp); err != nil { + return nil, 0, err + } + return resp.Entries, resp.TotalParticipants, nil +} + +// GetGlobalStats fetches aggregate statistics from the hub. +func (h *HubService) GetGlobalStats() (*GlobalStats, error) { + var stats GlobalStats + if err := h.doJSON("GET", "/stats", nil, &stats); err != nil { + return nil, err + } + return &stats, nil +} diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go new file mode 100644 index 0000000..e5236da --- /dev/null +++ b/internal/bugseti/hub_test.go @@ -0,0 +1,558 @@ +package bugseti + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "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()) +} + +// ---- doRequest ---- + +func TestDoRequest_Good(t *testing.T) { + var gotAuth string + var gotContentType string + var gotAccept string + var gotBody map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotContentType = r.Header.Get("Content-Type") + gotAccept = r.Header.Get("Accept") + + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "test-token-123" + h := NewHubService(cfg) + + body := map[string]string{"key": "value"} + resp, err := h.doRequest("POST", "/test", body) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "Bearer test-token-123", gotAuth) + assert.Equal(t, "application/json", gotContentType) + assert.Equal(t, "application/json", gotAccept) + assert.Equal(t, "value", gotBody["key"]) + assert.True(t, h.IsConnected()) +} + +func TestDoRequest_Bad_NoHubURL(t *testing.T) { + h := testHubService(t, "") + + resp, err := h.doRequest("GET", "/test", nil) + assert.Nil(t, resp) + assert.Error(t, err) + assert.Contains(t, err.Error(), "hub URL not configured") +} + +func TestDoRequest_Bad_NetworkError(t *testing.T) { + // Point to a port where nothing is listening. + h := testHubService(t, "http://127.0.0.1:1") + + resp, err := h.doRequest("GET", "/test", nil) + assert.Nil(t, resp) + assert.Error(t, err) + assert.False(t, h.IsConnected()) +} + +// ---- Task 4: AutoRegister ---- + +func TestAutoRegister_Good(t *testing.T) { + var gotBody map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/bugseti/auth/forge", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + _ = json.NewDecoder(r.Body).Decode(&gotBody) + + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"api_key":"ak_test_12345"}`)) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.ForgeURL = "https://forge.example.com" + cfg.config.ForgeToken = "forge-tok-abc" + h := NewHubService(cfg) + + err := h.AutoRegister() + require.NoError(t, err) + + // Verify token was cached. + assert.Equal(t, "ak_test_12345", h.config.GetHubToken()) + + // Verify request body. + assert.Equal(t, "https://forge.example.com", gotBody["forge_url"]) + assert.Equal(t, "forge-tok-abc", gotBody["forge_token"]) + assert.NotEmpty(t, gotBody["client_id"]) +} + +func TestAutoRegister_Bad_NoForgeToken(t *testing.T) { + // Isolate from user's real ~/.core/config.yaml and env vars. + origHome := os.Getenv("HOME") + t.Setenv("HOME", t.TempDir()) + t.Setenv("FORGE_TOKEN", "") + t.Setenv("FORGE_URL", "") + defer os.Setenv("HOME", origHome) + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = "https://hub.example.com" + // No forge token set, and env/config are empty in test. + h := NewHubService(cfg) + + err := h.AutoRegister() + require.Error(t, err) + assert.Contains(t, err.Error(), "no forge token available") +} + +func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) { + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = "https://hub.example.com" + cfg.config.HubToken = "existing-token" + h := NewHubService(cfg) + + err := h.AutoRegister() + require.NoError(t, err) + + // Token should remain unchanged. + assert.Equal(t, "existing-token", h.config.GetHubToken()) +} + +// ---- Task 5: 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) +} + +// ---- Task 6: Read Operations ---- + +func TestIsIssueClaimed_Good_Claimed(t *testing.T) { + now := time.Now().Truncate(time.Second) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/bugseti/issues/issue-42", r.URL.Path) + assert.Equal(t, "GET", r.Method) + + w.WriteHeader(http.StatusOK) + claim := HubClaim{ + ID: "claim-1", + IssueURL: "https://github.com/org/repo/issues/42", + ClientID: "client-abc", + ClaimedAt: now, + Status: "claimed", + } + _ = json.NewEncoder(w).Encode(claim) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + claim, err := h.IsIssueClaimed("issue-42") + require.NoError(t, err) + require.NotNil(t, claim) + assert.Equal(t, "claim-1", claim.ID) + assert.Equal(t, "claimed", claim.Status) +} + +func TestIsIssueClaimed_Good_NotClaimed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + claim, err := h.IsIssueClaimed("issue-999") + assert.NoError(t, err) + assert.Nil(t, claim) +} + +func TestGetLeaderboard_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/bugseti/leaderboard", r.URL.Path) + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "10", r.URL.Query().Get("limit")) + + resp := leaderboardResponse{ + Entries: []LeaderboardEntry{ + {ClientID: "a", ClientName: "Alice", Score: 100, PRsMerged: 10, Rank: 1}, + {ClientID: "b", ClientName: "Bob", Score: 80, PRsMerged: 8, Rank: 2}, + }, + TotalParticipants: 42, + } + w.WriteHeader(http.StatusOK) + _ = 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) + + entries, total, err := h.GetLeaderboard(10) + require.NoError(t, err) + assert.Equal(t, 42, total) + require.Len(t, entries, 2) + assert.Equal(t, "Alice", entries[0].ClientName) + assert.Equal(t, 1, entries[0].Rank) + assert.Equal(t, "Bob", entries[1].ClientName) +} + +func TestGetGlobalStats_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/bugseti/stats", r.URL.Path) + assert.Equal(t, "GET", r.Method) + + stats := GlobalStats{ + TotalClients: 100, + TotalClaims: 500, + TotalPRsMerged: 300, + ActiveClaims: 25, + IssuesAvailable: 150, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(stats) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + stats, err := h.GetGlobalStats() + require.NoError(t, err) + require.NotNil(t, stats) + assert.Equal(t, 100, stats.TotalClients) + assert.Equal(t, 500, stats.TotalClaims) + assert.Equal(t, 300, stats.TotalPRsMerged) + assert.Equal(t, 25, stats.ActiveClaims) + assert.Equal(t, 150, stats.IssuesAvailable) +} + +// ---- Task 7: Pending Operations Queue ---- + +func TestPendingOps_Good_QueueAndDrain(t *testing.T) { + var callCount int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + // Manually queue a pending op (simulates a previous failed request). + h.queueOp("POST", "/heartbeat", map[string]string{"client_id": "test"}) + assert.Equal(t, 1, h.PendingCount()) + + // Register() calls drainPendingOps() first, then sends its own request. + err := h.Register() + require.NoError(t, err) + + // At least 2 calls: 1 from drain (the queued heartbeat) + 1 from Register itself. + assert.GreaterOrEqual(t, callCount, int32(2)) + assert.Equal(t, 0, h.PendingCount()) +} + +func TestPendingOps_Good_PersistAndLoad(t *testing.T) { + cfg1 := testConfigService(t, nil, nil) + cfg1.config.HubURL = "https://hub.example.com" + cfg1.config.HubToken = "tok" + h1 := NewHubService(cfg1) + + // Queue an op — this also calls savePendingOps. + h1.queueOp("POST", "/heartbeat", map[string]string{"client_id": "test"}) + assert.Equal(t, 1, h1.PendingCount()) + + // Create a second HubService with the same data dir. + // NewHubService calls loadPendingOps() in its constructor. + cfg2 := testConfigService(t, nil, nil) + cfg2.config.DataDir = cfg1.config.DataDir // Share the same data dir. + cfg2.config.HubURL = "https://hub.example.com" + cfg2.config.HubToken = "tok" + h2 := NewHubService(cfg2) + + assert.Equal(t, 1, h2.PendingCount()) +} + +func TestPendingCount_Good(t *testing.T) { + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = "https://hub.example.com" + cfg.config.HubToken = "tok" + h := NewHubService(cfg) + + assert.Equal(t, 0, h.PendingCount()) + + h.queueOp("POST", "/test1", nil) + assert.Equal(t, 1, h.PendingCount()) + + h.queueOp("POST", "/test2", map[string]string{"key": "val"}) + assert.Equal(t, 2, h.PendingCount()) +}