From 39d6dccbf8357c2a67925b1b32ed9d72c41c1b5c Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:12:53 +0000 Subject: [PATCH 01/10] docs: add BugSETI HubService design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin HTTP client for portal coordination API — issue claiming, stats sync, leaderboard, auto-register via forge token. Co-Authored-By: Virgil --- .../2026-02-13-bugseti-hub-service-design.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 docs/plans/2026-02-13-bugseti-hub-service-design.md 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 From 1f3e6ba4abe257ca2c565e2b514a34f793197ceb Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:17:59 +0000 Subject: [PATCH 02/10] 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) | From 0af6407666be3204133f74f49f817fd5d636c884 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:23:02 +0000 Subject: [PATCH 03/10] feat(bugseti): add hub coordination config fields and accessors Add HubURL, HubToken, ClientID, and ClientName fields to Config struct for agentic portal integration. Include getter/setter methods following the existing pattern (SetForgeURL, SetForgeToken also added). Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- internal/bugseti/config.go | 82 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) 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() From f85bba53321a222c9bc5020b718eaf48d170bb6e Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:24:38 +0000 Subject: [PATCH 04/10] feat(bugseti): add HubService types and constructor Introduce HubService struct with types for hub coordination: PendingOp, HubClaim, LeaderboardEntry, GlobalStats, ConflictError, NotFoundError. Constructor generates a crypto/rand client ID when none exists. Includes no-op loadPendingOps/savePendingOps stubs for future persistence. Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- internal/bugseti/hub.go | 129 +++++++++++++++++++++++++++++++++++ internal/bugseti/hub_test.go | 48 +++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 internal/bugseti/hub.go create mode 100644 internal/bugseti/hub_test.go diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go new file mode 100644 index 0000000..79d54cf --- /dev/null +++ b/internal/bugseti/hub.go @@ -0,0 +1,129 @@ +// Package bugseti provides services for the BugSETI distributed bug fixing application. +package bugseti + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "sync" + "time" +) + +// HubService coordinates with the agentic portal for issue assignment and leaderboard. +type HubService struct { + config *ConfigService + client *http.Client + connected bool + pending []PendingOp + mu sync.RWMutex +} + +// PendingOp represents an operation queued for retry when the hub is unreachable. +type PendingOp struct { + Method string `json:"method"` + Path string `json:"path"` + Body interface{} `json:"body,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// HubClaim represents a claimed issue from the hub. +type HubClaim struct { + ID string `json:"id"` + IssueURL string `json:"issueUrl"` + ClientID string `json:"clientId"` + ClaimedAt time.Time `json:"claimedAt"` + ExpiresAt time.Time `json:"expiresAt"` + Status string `json:"status"` +} + +// LeaderboardEntry represents a single entry on the leaderboard. +type LeaderboardEntry struct { + ClientID string `json:"clientId"` + ClientName string `json:"clientName"` + Score int `json:"score"` + PRsMerged int `json:"prsMerged"` + Rank int `json:"rank"` +} + +// GlobalStats holds aggregate statistics from the hub. +type GlobalStats struct { + TotalClients int `json:"totalClients"` + TotalClaims int `json:"totalClaims"` + TotalPRsMerged int `json:"totalPrsMerged"` + ActiveClaims int `json:"activeClaims"` + IssuesAvailable int `json:"issuesAvailable"` +} + +// ConflictError indicates a 409 response from the hub (e.g. issue already claimed). +type ConflictError struct { + StatusCode int +} + +func (e *ConflictError) Error() string { + return fmt.Sprintf("conflict: status %d", e.StatusCode) +} + +// NotFoundError indicates a 404 response from the hub. +type NotFoundError struct { + StatusCode int +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("not found: status %d", e.StatusCode) +} + +// NewHubService creates a new HubService with the given config. +// If the config has no ClientID, one is generated and persisted. +func NewHubService(config *ConfigService) *HubService { + h := &HubService{ + config: config, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + pending: make([]PendingOp, 0), + } + + // Generate client ID if not set. + if config.GetClientID() == "" { + id := generateClientID() + _ = config.SetClientID(id) + } + + h.loadPendingOps() + + return h +} + +// ServiceName returns the service name for Wails. +func (h *HubService) ServiceName() string { + return "HubService" +} + +// GetClientID returns the client ID from config. +func (h *HubService) GetClientID() string { + return h.config.GetClientID() +} + +// IsConnected returns whether the hub was reachable on the last request. +func (h *HubService) IsConnected() bool { + h.mu.RLock() + defer h.mu.RUnlock() + return h.connected +} + +// generateClientID creates a random hex string (16 bytes = 32 hex chars). +func generateClientID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Fallback: this should never happen with crypto/rand. + return fmt.Sprintf("fallback-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +// loadPendingOps is a no-op placeholder (disk persistence comes in Task 7). +func (h *HubService) loadPendingOps() {} + +// savePendingOps is a no-op placeholder (disk persistence comes in Task 7). +func (h *HubService) savePendingOps() {} diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go new file mode 100644 index 0000000..b04cb15 --- /dev/null +++ b/internal/bugseti/hub_test.go @@ -0,0 +1,48 @@ +package bugseti + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testHubService(t *testing.T, serverURL string) *HubService { + t.Helper() + cfg := testConfigService(t, nil, nil) + if serverURL != "" { + cfg.config.HubURL = serverURL + } + return NewHubService(cfg) +} + +// ---- NewHubService ---- + +func TestNewHubService_Good(t *testing.T) { + h := testHubService(t, "") + require.NotNil(t, h) + assert.NotNil(t, h.config) + assert.NotNil(t, h.client) + assert.False(t, h.IsConnected()) +} + +func TestHubServiceName_Good(t *testing.T) { + h := testHubService(t, "") + assert.Equal(t, "HubService", h.ServiceName()) +} + +func TestNewHubService_Good_GeneratesClientID(t *testing.T) { + h := testHubService(t, "") + id := h.GetClientID() + assert.NotEmpty(t, id) + // 16 bytes = 32 hex characters + assert.Len(t, id, 32) +} + +func TestNewHubService_Good_ReusesClientID(t *testing.T) { + cfg := testConfigService(t, nil, nil) + cfg.config.ClientID = "existing-client-id" + + h := NewHubService(cfg) + assert.Equal(t, "existing-client-id", h.GetClientID()) +} From 74bb62fda800abbf680a7950acf26d81f5c726ca Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:25:28 +0000 Subject: [PATCH 05/10] feat(bugseti): add HubService HTTP request helpers Add doRequest() and doJSON() methods for hub API communication. doRequest builds full URLs, sets bearer auth and JSON headers, tracks connected state. doJSON handles status codes: 401 unauthorised, 409 ConflictError, 404 NotFoundError, and generic errors for other 4xx/5xx responses. Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- internal/bugseti/hub.go | 80 ++++++++++++++++++++++++++++++++++++ internal/bugseti/hub_test.go | 62 ++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go index 79d54cf..7060b7c 100644 --- a/internal/bugseti/hub.go +++ b/internal/bugseti/hub.go @@ -2,9 +2,12 @@ package bugseti import ( + "bytes" "crypto/rand" "encoding/hex" + "encoding/json" "fmt" + "io" "net/http" "sync" "time" @@ -122,6 +125,83 @@ func generateClientID() string { return hex.EncodeToString(b) } +// doRequest builds and executes an HTTP request against the hub API. +// It returns the raw *http.Response and any transport-level error. +func (h *HubService) doRequest(method, path string, body interface{}) (*http.Response, error) { + hubURL := h.config.GetHubURL() + if hubURL == "" { + return nil, fmt.Errorf("hub URL not configured") + } + + fullURL := hubURL + "/api/bugseti" + path + + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, fullURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + token := h.config.GetHubToken() + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := h.client.Do(req) + if err != nil { + h.mu.Lock() + h.connected = false + h.mu.Unlock() + return nil, err + } + + h.mu.Lock() + h.connected = true + h.mu.Unlock() + + return resp, nil +} + +// doJSON executes an HTTP request and decodes the JSON response into dest. +// It handles common error status codes with typed errors. +func (h *HubService) doJSON(method, path string, body, dest interface{}) error { + resp, err := h.doRequest(method, path, body) + if err != nil { + return err + } + defer resp.Body.Close() + + switch { + case resp.StatusCode == http.StatusUnauthorized: + return fmt.Errorf("unauthorised") + case resp.StatusCode == http.StatusConflict: + return &ConflictError{StatusCode: resp.StatusCode} + case resp.StatusCode == http.StatusNotFound: + return &NotFoundError{StatusCode: resp.StatusCode} + case resp.StatusCode >= 400: + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("hub error %d: %s", resp.StatusCode, string(respBody)) + } + + if dest != nil { + if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + + return nil +} + // loadPendingOps is a no-op placeholder (disk persistence comes in Task 7). func (h *HubService) loadPendingOps() {} diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go index b04cb15..70b402a 100644 --- a/internal/bugseti/hub_test.go +++ b/internal/bugseti/hub_test.go @@ -1,6 +1,9 @@ package bugseti import ( + "encoding/json" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -46,3 +49,62 @@ func TestNewHubService_Good_ReusesClientID(t *testing.T) { 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()) +} From f963a45d9f56e47301981fe888c030046a06d7de Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:31:23 +0000 Subject: [PATCH 06/10] feat(bugseti): add AutoRegister via Forge token exchange Exchange a Forge API token for a hub API key by POSTing to /api/bugseti/auth/forge. Skips if hub token already cached. Adds drainPendingOps() stub for future Task 7 use. Co-Authored-By: Virgil --- internal/bugseti/hub.go | 80 ++++++++++++++++++++++++++++++++++++ internal/bugseti/hub_test.go | 66 +++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go index 7060b7c..09ed242 100644 --- a/internal/bugseti/hub.go +++ b/internal/bugseti/hub.go @@ -8,9 +8,12 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "sync" "time" + + "github.com/host-uk/core/pkg/forge" ) // HubService coordinates with the agentic portal for issue assignment and leaderboard. @@ -207,3 +210,80 @@ func (h *HubService) loadPendingOps() {} // savePendingOps is a no-op placeholder (disk persistence comes in Task 7). func (h *HubService) savePendingOps() {} + +// drainPendingOps replays queued operations (no-op until Task 7). +func (h *HubService) drainPendingOps() {} + +// AutoRegister exchanges a Forge API token for a hub API key. +// If a hub token is already configured, this is a no-op. +func (h *HubService) AutoRegister() error { + // Skip if already registered. + if h.config.GetHubToken() != "" { + return nil + } + + hubURL := h.config.GetHubURL() + if hubURL == "" { + return fmt.Errorf("hub URL not configured") + } + + // Resolve forge credentials from config/env. + forgeURL := h.config.GetForgeURL() + forgeToken := h.config.GetForgeToken() + if forgeToken == "" { + resolvedURL, resolvedToken, err := forge.ResolveConfig(forgeURL, "") + if err != nil { + return fmt.Errorf("resolve forge config: %w", err) + } + forgeURL = resolvedURL + forgeToken = resolvedToken + } + + if forgeToken == "" { + return fmt.Errorf("no forge token available (set FORGE_TOKEN or run: core forge config --token TOKEN)") + } + + // Build request body. + payload := map[string]string{ + "forge_url": forgeURL, + "forge_token": forgeToken, + "client_id": h.config.GetClientID(), + } + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal auto-register body: %w", err) + } + + // POST directly (no bearer token yet). + resp, err := h.client.Post(hubURL+"/api/bugseti/auth/forge", "application/json", bytes.NewReader(data)) + if err != nil { + h.mu.Lock() + h.connected = false + h.mu.Unlock() + return fmt.Errorf("auto-register request: %w", err) + } + defer resp.Body.Close() + + h.mu.Lock() + h.connected = true + h.mu.Unlock() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("auto-register failed %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + APIKey string `json:"api_key"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decode auto-register response: %w", err) + } + + if err := h.config.SetHubToken(result.APIKey); err != nil { + return fmt.Errorf("cache hub token: %w", err) + } + + log.Printf("BugSETI: auto-registered with hub, token cached") + return nil +} diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go index 70b402a..80f49d0 100644 --- a/internal/bugseti/hub_test.go +++ b/internal/bugseti/hub_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" "github.com/stretchr/testify/assert" @@ -108,3 +109,68 @@ func TestDoRequest_Bad_NetworkError(t *testing.T) { assert.Error(t, err) assert.False(t, h.IsConnected()) } + +// ---- 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()) +} From d583a074f7a4f04a891be5d2df7d57b6aac93dfb Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:32:57 +0000 Subject: [PATCH 07/10] feat(bugseti): add hub write operations Add Register, Heartbeat, ClaimIssue, UpdateStatus, ReleaseClaim, and SyncStats methods for hub coordination. ClaimIssue returns ConflictError on 409 and calls drainPendingOps before mutating. Co-Authored-By: Virgil --- internal/bugseti/hub.go | 110 +++++++++++++++++++ internal/bugseti/hub_test.go | 204 +++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go index 09ed242..7573baa 100644 --- a/internal/bugseti/hub.go +++ b/internal/bugseti/hub.go @@ -10,6 +10,8 @@ import ( "io" "log" "net/http" + "net/url" + "runtime" "sync" "time" @@ -287,3 +289,111 @@ func (h *HubService) AutoRegister() error { log.Printf("BugSETI: auto-registered with hub, token cached") return nil } + +// 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) +} diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go index 80f49d0..263e688 100644 --- a/internal/bugseti/hub_test.go +++ b/internal/bugseti/hub_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -174,3 +175,206 @@ func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) { // Token should remain unchanged. assert.Equal(t, "existing-token", h.config.GetHubToken()) } + +// ---- 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) +} From 5d0b6c3a71b77b00c531b66f82ffa876ecfa232f Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:33:11 +0000 Subject: [PATCH 08/10] feat(bugseti): add hub read operations Add IsIssueClaimed, ListClaims, GetLeaderboard, and GetGlobalStats methods. IsIssueClaimed returns (nil, nil) on 404 for unclaimed issues. GetLeaderboard returns entries and total participant count. Co-Authored-By: Virgil --- internal/bugseti/hub.go | 87 +++++++++++++++++++++++--- internal/bugseti/hub_test.go | 117 ++++++++++++++++++++++++++++++++++- 2 files changed, 193 insertions(+), 11 deletions(-) diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go index 7573baa..70ed111 100644 --- a/internal/bugseti/hub.go +++ b/internal/bugseti/hub.go @@ -216,6 +216,8 @@ func (h *HubService) savePendingOps() {} // drainPendingOps replays queued operations (no-op until Task 7). func (h *HubService) drainPendingOps() {} +// ---- 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 { @@ -290,6 +292,8 @@ func (h *HubService) AutoRegister() error { return nil } +// ---- Task 5: Write Operations ---- + // Register registers this client with the hub. func (h *HubService) Register() error { h.drainPendingOps() @@ -382,18 +386,83 @@ func (h *HubService) SyncStats(stats *Stats) error { 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, + "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, + "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 index 263e688..206b34b 100644 --- a/internal/bugseti/hub_test.go +++ b/internal/bugseti/hub_test.go @@ -111,7 +111,7 @@ func TestDoRequest_Bad_NetworkError(t *testing.T) { assert.False(t, h.IsConnected()) } -// ---- AutoRegister ---- +// ---- Task 4: AutoRegister ---- func TestAutoRegister_Good(t *testing.T) { var gotBody map[string]string @@ -176,7 +176,7 @@ func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) { assert.Equal(t, "existing-token", h.config.GetHubToken()) } -// ---- Write Operations ---- +// ---- Task 5: Write Operations ---- func TestRegister_Good(t *testing.T) { var gotPath string @@ -378,3 +378,116 @@ func TestSyncStats_Good(t *testing.T) { 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) +} From 2a8b5c207f7aeda1712509bdc69670bf5c5400fa Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:36:08 +0000 Subject: [PATCH 09/10] feat(bugseti): implement pending operations queue with disk persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace no-op stubs with real implementations for queueOp, drainPendingOps, savePendingOps, and loadPendingOps. Operations are persisted to hub_pending.json and replayed on next hub connection — 5xx/transport errors are retried, 4xx responses are dropped as stale. Adds PendingCount() for queue inspection. Co-Authored-By: Virgil --- internal/bugseti/hub.go | 122 +++++++++++++++++++++++++++++++++-- internal/bugseti/hub_test.go | 65 +++++++++++++++++++ 2 files changed, 180 insertions(+), 7 deletions(-) diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go index 70ed111..83f8367 100644 --- a/internal/bugseti/hub.go +++ b/internal/bugseti/hub.go @@ -11,6 +11,8 @@ import ( "log" "net/http" "net/url" + "os" + "path/filepath" "runtime" "sync" "time" @@ -31,7 +33,7 @@ type HubService struct { type PendingOp struct { Method string `json:"method"` Path string `json:"path"` - Body interface{} `json:"body,omitempty"` + Body json.RawMessage `json:"body,omitempty"` CreatedAt time.Time `json:"createdAt"` } @@ -207,14 +209,120 @@ func (h *HubService) doJSON(method, path string, body, dest interface{}) error { return nil } -// loadPendingOps is a no-op placeholder (disk persistence comes in Task 7). -func (h *HubService) loadPendingOps() {} +// 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 + } -// savePendingOps is a no-op placeholder (disk persistence comes in Task 7). -func (h *HubService) savePendingOps() {} + h.mu.Lock() + h.pending = append(h.pending, PendingOp{ + Method: method, + Path: path, + Body: raw, + CreatedAt: time.Now(), + }) + h.mu.Unlock() -// drainPendingOps replays queued operations (no-op until Task 7). -func (h *HubService) drainPendingOps() {} + 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 ---- diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go index 206b34b..e5236da 100644 --- a/internal/bugseti/hub_test.go +++ b/internal/bugseti/hub_test.go @@ -491,3 +491,68 @@ func TestGetGlobalStats_Good(t *testing.T) { 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()) +} From c72f35bd3f264bc780c353f851e9c244c0989a1f Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:36:59 +0000 Subject: [PATCH 10/10] feat(bugseti): wire HubService into main.go with auto-registration Add HubService to the Wails service list and attempt hub registration at startup when hubUrl is configured. Drains any pending operations queued from previous sessions. Co-Authored-By: Virgil --- cmd/bugseti/main.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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) }