From 1f3e6ba4abe257ca2c565e2b514a34f793197ceb Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:17:59 +0000 Subject: [PATCH] docs: add BugSETI HubService implementation plan 10 tasks covering Go client + Laravel auth endpoint. TDD approach with httptest mocks. Co-Authored-By: Virgil --- .../2026-02-13-bugseti-hub-service-plan.md | 1620 +++++++++++++++++ 1 file changed, 1620 insertions(+) create mode 100644 docs/plans/2026-02-13-bugseti-hub-service-plan.md 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) |