From 3b77adaaa35b468dee6862d711a65b94dc628604 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 14:56:00 +0000 Subject: [PATCH] refactor: add i18n-validate tool, remove bugseti plan docs Move i18n-validate tool from core/cli internal/tools/ into pkg/i18n/internal/validate/. Remove bugseti plan docs (now in core/bugseti repo). Co-Authored-By: Claude Opus 4.6 --- .../2026-02-13-bugseti-hub-service-design.md | 150 -- .../2026-02-13-bugseti-hub-service-plan.md | 1620 ----------------- pkg/i18n/internal/validate/main.go | 525 ++++++ 3 files changed, 525 insertions(+), 1770 deletions(-) delete mode 100644 docs/plans/2026-02-13-bugseti-hub-service-design.md delete mode 100644 docs/plans/2026-02-13-bugseti-hub-service-plan.md create mode 100644 pkg/i18n/internal/validate/main.go diff --git a/docs/plans/2026-02-13-bugseti-hub-service-design.md b/docs/plans/2026-02-13-bugseti-hub-service-design.md deleted file mode 100644 index 2f132e4..0000000 --- a/docs/plans/2026-02-13-bugseti-hub-service-design.md +++ /dev/null @@ -1,150 +0,0 @@ -# BugSETI HubService Design - -## Overview - -A thin HTTP client service in the BugSETI desktop app that coordinates with the agentic portal's `/api/bugseti/*` endpoints. Prevents duplicate work across the 11 community testers, aggregates stats for leaderboard, and registers client instances. - -## Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Target | Direct to portal API | Endpoints built for this purpose | -| Auth | Auto-register via forge token | No manual key management for users | -| Sync strategy | Lazy/manual | User-triggered claims, manual stats sync | -| Offline mode | Offline-first | Queue failed writes, retry on reconnect | -| Approach | Thin HTTP client (net/http) | Matches existing patterns, no deps | - -## Architecture - -**File:** `internal/bugseti/hub.go` + `hub_test.go` - -``` -HubService -├── HTTP client (net/http, 10s timeout) -├── Auth: auto-register via forge token → cached ak_ token -├── Config: HubURL, HubToken, ClientID in ConfigService -├── Offline-first: queue failed writes, drain on next success -└── Lazy sync: user-triggered, no background goroutines -``` - -**Dependencies:** ConfigService only. - -**Integration:** -- QueueService calls `hub.ClaimIssue()` when user picks an issue -- SubmitService calls `hub.UpdateStatus("completed")` after PR -- TrayService calls `hub.GetLeaderboard()` from UI -- main.go calls `hub.Register()` on startup - -## Data Types - -```go -type HubClient struct { - ClientID string // UUID, generated once, persisted in config - Name string // e.g. "Snider's MacBook" - Version string // bugseti.GetVersion() - OS string // runtime.GOOS - Arch string // runtime.GOARCH -} - -type HubClaim struct { - IssueID string // "owner/repo#123" - Repo string - IssueNumber int - Title string - URL string - Status string // claimed|in_progress|completed|skipped - ClaimedAt time.Time - PRUrl string - PRNumber int -} - -type LeaderboardEntry struct { - Rank int - ClientName string - IssuesCompleted int - PRsSubmitted int - PRsMerged int - CurrentStreak int -} - -type GlobalStats struct { - TotalParticipants int - ActiveParticipants int - TotalIssuesCompleted int - TotalPRsMerged int - ActiveClaims int -} -``` - -## API Mapping - -| Method | HTTP | Endpoint | Trigger | -|--------|------|----------|---------| -| `Register()` | POST /register | App startup | -| `Heartbeat()` | POST /heartbeat | Manual / periodic if enabled | -| `ClaimIssue(issue)` | POST /issues/claim | User picks issue | -| `UpdateStatus(id, status)` | PATCH /issues/{id}/status | PR submitted, skip | -| `ReleaseClaim(id)` | DELETE /issues/{id}/claim | User abandons | -| `IsIssueClaimed(id)` | GET /issues/{id} | Before showing issue | -| `ListClaims(filters)` | GET /issues/claimed | UI active claims view | -| `SyncStats(stats)` | POST /stats/sync | Manual from UI | -| `GetLeaderboard(limit)` | GET /leaderboard | UI leaderboard view | -| `GetGlobalStats()` | GET /stats | UI stats dashboard | - -## Auto-Register Flow - -New endpoint on portal: - -``` -POST /api/bugseti/auth/forge -Body: { "forge_url": "https://forge.lthn.io", "forge_token": "..." } -``` - -Portal validates token against Forgejo API (`/api/v1/user`), creates an AgentApiKey with `bugseti.read` + `bugseti.write` scopes, returns `{ "api_key": "ak_..." }`. - -HubService caches the `ak_` token in config.json. On 401, clears cached token and re-registers. - -## Error Handling - -| Error | Behaviour | -|-------|-----------| -| Network unreachable | Log, queue write ops, return cached reads | -| 401 Unauthorised | Clear token, re-register via forge | -| 409 Conflict (claim) | Return "already claimed" — not an error | -| 404 (claim not found) | Return nil | -| 429 Rate limited | Back off, queue the op | -| 5xx Server error | Log, queue write ops | - -**Pending operations queue:** -- Failed writes stored in `[]PendingOp`, persisted to `$DataDir/hub_pending.json` -- Drained on next successful user-triggered call (no background goroutine) -- Each op has: method, path, body, created_at - -## Config Changes - -New fields in `Config` struct: - -```go -HubURL string `json:"hubUrl,omitempty"` // portal API base URL -HubToken string `json:"hubToken,omitempty"` // cached ak_ token -ClientID string `json:"clientId,omitempty"` // UUID, generated once -ClientName string `json:"clientName,omitempty"` // display name -``` - -## Files Changed - -| File | Action | -|------|--------| -| `internal/bugseti/hub.go` | New — HubService | -| `internal/bugseti/hub_test.go` | New — httptest-based tests | -| `internal/bugseti/config.go` | Edit — add Hub* + ClientID fields | -| `cmd/bugseti/main.go` | Edit — create + register HubService | -| `cmd/bugseti/tray.go` | Edit — leaderboard/stats menu items | -| Laravel: auth controller | New — `/api/bugseti/auth/forge` | - -## Testing - -- `httptest.NewServer` mocks for all endpoints -- Test success, network error, 409 conflict, 401 re-auth flows -- Test pending ops queue: add when offline, drain on reconnect -- `_Good`, `_Bad`, `_Ugly` naming convention diff --git a/docs/plans/2026-02-13-bugseti-hub-service-plan.md b/docs/plans/2026-02-13-bugseti-hub-service-plan.md deleted file mode 100644 index 2b9e3bb..0000000 --- a/docs/plans/2026-02-13-bugseti-hub-service-plan.md +++ /dev/null @@ -1,1620 +0,0 @@ -# BugSETI HubService Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a HubService to BugSETI that coordinates issue claiming, stats sync, and leaderboard with the agentic portal API. - -**Architecture:** Thin HTTP client (`net/http`) in `internal/bugseti/hub.go` talking directly to the portal's `/api/bugseti/*` endpoints. Auto-registers via forge token to get an `ak_` bearer token. Offline-first with pending-ops queue. - -**Tech Stack:** Go stdlib (`net/http`, `encoding/json`), Laravel 12 (portal endpoint), httptest (Go tests) - ---- - -### Task 1: Config Fields - -Add hub-related fields to the Config struct so HubService can persist its state. - -**Files:** -- Modify: `internal/bugseti/config.go` -- Test: `internal/bugseti/fetcher_test.go` (uses `testConfigService`) - -**Step 1: Add config fields** - -In `internal/bugseti/config.go`, add these fields to the `Config` struct after the `ForgeToken` field: - -```go -// Hub coordination (agentic portal) -HubURL string `json:"hubUrl,omitempty"` // Portal API base URL (e.g. https://leth.in) -HubToken string `json:"hubToken,omitempty"` // Cached ak_ bearer token -ClientID string `json:"clientId,omitempty"` // UUID, generated once on first run -ClientName string `json:"clientName,omitempty"` // Display name for leaderboard -``` - -**Step 2: Add getters/setters** - -After the `GetForgeToken()` method, add: - -```go -// GetHubURL returns the hub portal URL. -func (c *ConfigService) GetHubURL() string { - c.mu.RLock() - defer c.mu.RUnlock() - return c.config.HubURL -} - -// SetHubURL sets the hub portal URL. -func (c *ConfigService) SetHubURL(url string) error { - c.mu.Lock() - defer c.mu.Unlock() - c.config.HubURL = url - return c.saveUnsafe() -} - -// GetHubToken returns the cached hub API token. -func (c *ConfigService) GetHubToken() string { - c.mu.RLock() - defer c.mu.RUnlock() - return c.config.HubToken -} - -// SetHubToken caches the hub API token. -func (c *ConfigService) SetHubToken(token string) error { - c.mu.Lock() - defer c.mu.Unlock() - c.config.HubToken = token - return c.saveUnsafe() -} - -// GetClientID returns the persistent client UUID. -func (c *ConfigService) GetClientID() string { - c.mu.RLock() - defer c.mu.RUnlock() - return c.config.ClientID -} - -// SetClientID sets the persistent client UUID. -func (c *ConfigService) SetClientID(id string) error { - c.mu.Lock() - defer c.mu.Unlock() - c.config.ClientID = id - return c.saveUnsafe() -} - -// GetClientName returns the display name. -func (c *ConfigService) GetClientName() string { - c.mu.RLock() - defer c.mu.RUnlock() - return c.config.ClientName -} - -// SetClientName sets the display name. -func (c *ConfigService) SetClientName(name string) error { - c.mu.Lock() - defer c.mu.Unlock() - c.config.ClientName = name - return c.saveUnsafe() -} -``` - -**Step 3: Run tests** - -Run: `go test ./internal/bugseti/... -count=1` -Expected: All existing tests pass (config fields are additive, no breakage). - -**Step 4: Commit** - -```bash -git add internal/bugseti/config.go -git commit -m "feat(bugseti): add hub config fields (HubURL, HubToken, ClientID, ClientName)" -``` - ---- - -### Task 2: HubService Core — Types and Constructor - -Create the HubService with data types, constructor, and ServiceName. - -**Files:** -- Create: `internal/bugseti/hub.go` -- Create: `internal/bugseti/hub_test.go` - -**Step 1: Write failing tests** - -Create `internal/bugseti/hub_test.go`: - -```go -package bugseti - -import ( - "testing" -) - -func testHubService(t *testing.T, serverURL string) *HubService { - t.Helper() - cfg := testConfigService(t, nil, nil) - if serverURL != "" { - cfg.config.HubURL = serverURL - } - return NewHubService(cfg) -} - -// --- Constructor / ServiceName --- - -func TestNewHubService_Good(t *testing.T) { - h := testHubService(t, "") - if h == nil { - t.Fatal("expected non-nil HubService") - } - if h.config == nil { - t.Fatal("expected config to be set") - } -} - -func TestHubServiceName_Good(t *testing.T) { - h := testHubService(t, "") - if got := h.ServiceName(); got != "HubService" { - t.Fatalf("expected HubService, got %s", got) - } -} - -func TestNewHubService_Good_GeneratesClientID(t *testing.T) { - h := testHubService(t, "") - id := h.GetClientID() - if id == "" { - t.Fatal("expected client ID to be generated") - } - if len(id) < 32 { - t.Fatalf("expected UUID-length client ID, got %d chars", len(id)) - } -} - -func TestNewHubService_Good_ReusesClientID(t *testing.T) { - cfg := testConfigService(t, nil, nil) - cfg.config.ClientID = "existing-id-12345" - h := NewHubService(cfg) - if h.GetClientID() != "existing-id-12345" { - t.Fatal("expected existing client ID to be preserved") - } -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `go test ./internal/bugseti/... -run TestNewHubService -count=1` -Expected: FAIL — `NewHubService` not defined. - -**Step 3: Write HubService core** - -Create `internal/bugseti/hub.go`: - -```go -// Package bugseti provides services for the BugSETI distributed bug fixing application. -package bugseti - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "net/url" - "runtime" - "sync" - "time" -) - -// HubService coordinates with the agentic portal for issue claiming, -// stats sync, and leaderboard. -type HubService struct { - config *ConfigService - httpClient *http.Client - mu sync.Mutex - connected bool - pendingOps []PendingOp -} - -// PendingOp represents a failed write operation queued for retry. -type PendingOp struct { - Method string `json:"method"` - Path string `json:"path"` - Body []byte `json:"body"` - CreatedAt time.Time `json:"createdAt"` -} - -// HubClaim represents an issue claim from the portal. -type HubClaim struct { - IssueID string `json:"issue_id"` - Repo string `json:"repo"` - IssueNumber int `json:"issue_number"` - Title string `json:"issue_title"` - URL string `json:"issue_url"` - Status string `json:"status"` - ClaimedAt time.Time `json:"claimed_at"` - PRUrl string `json:"pr_url,omitempty"` - PRNumber int `json:"pr_number,omitempty"` -} - -// LeaderboardEntry represents a single entry on the leaderboard. -type LeaderboardEntry struct { - Rank int `json:"rank"` - ClientName string `json:"client_name"` - ClientVersion string `json:"client_version,omitempty"` - IssuesCompleted int `json:"issues_completed"` - PRsSubmitted int `json:"prs_submitted"` - PRsMerged int `json:"prs_merged"` - CurrentStreak int `json:"current_streak"` - LongestStreak int `json:"longest_streak"` -} - -// GlobalStats represents aggregate stats from the portal. -type GlobalStats struct { - TotalParticipants int `json:"total_participants"` - ActiveParticipants int `json:"active_participants"` - TotalIssuesAttempted int `json:"total_issues_attempted"` - TotalIssuesCompleted int `json:"total_issues_completed"` - TotalPRsSubmitted int `json:"total_prs_submitted"` - TotalPRsMerged int `json:"total_prs_merged"` - ActiveClaims int `json:"active_claims"` - CompletedClaims int `json:"completed_claims"` -} - -// NewHubService creates a new HubService. -func NewHubService(config *ConfigService) *HubService { - h := &HubService{ - config: config, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - } - - // Ensure a persistent client ID exists - if config.GetClientID() == "" { - id := generateClientID() - if err := config.SetClientID(id); err != nil { - log.Printf("Warning: could not persist client ID: %v", err) - } - } - - // Load pending ops from disk - h.loadPendingOps() - - return h -} - -// ServiceName returns the service name for Wails. -func (h *HubService) ServiceName() string { - return "HubService" -} - -// GetClientID returns the persistent client identifier. -func (h *HubService) GetClientID() string { - return h.config.GetClientID() -} - -// IsConnected returns whether the last hub request succeeded. -func (h *HubService) IsConnected() bool { - h.mu.Lock() - defer h.mu.Unlock() - return h.connected -} - -// generateClientID creates a random hex client identifier. -func generateClientID() string { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - // Fallback to timestamp-based ID - return fmt.Sprintf("bugseti-%d", time.Now().UnixNano()) - } - return hex.EncodeToString(b) -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/bugseti/... -run TestNewHubService -count=1 && go test ./internal/bugseti/... -run TestHubServiceName -count=1` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/bugseti/hub.go internal/bugseti/hub_test.go -git commit -m "feat(bugseti): add HubService types and constructor" -``` - ---- - -### Task 3: HTTP Request Helpers - -Add the internal `doRequest` and `doJSON` methods that all API calls use. - -**Files:** -- Modify: `internal/bugseti/hub.go` -- Modify: `internal/bugseti/hub_test.go` - -**Step 1: Write failing tests** - -Add to `hub_test.go`: - -```go -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -func TestDoRequest_Good(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer test-token" { - t.Fatal("expected bearer token") - } - if r.Header.Get("Content-Type") != "application/json" { - t.Fatal("expected JSON content type") - } - w.WriteHeader(200) - w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "test-token" - - resp, err := h.doRequest("GET", "/test", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Fatalf("expected 200, got %d", resp.StatusCode) - } -} - -func TestDoRequest_Bad_NoHubURL(t *testing.T) { - h := testHubService(t, "") - _, err := h.doRequest("GET", "/test", nil) - if err == nil { - t.Fatal("expected error when hub URL is empty") - } -} - -func TestDoRequest_Bad_NetworkError(t *testing.T) { - h := testHubService(t, "http://127.0.0.1:1") // Nothing listening - h.config.config.HubToken = "test-token" - - _, err := h.doRequest("GET", "/test", nil) - if err == nil { - t.Fatal("expected network error") - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/bugseti/... -run TestDoRequest -count=1` -Expected: FAIL — `doRequest` not defined. - -**Step 3: Implement helpers** - -Add to `hub.go`: - -```go -// doRequest performs an HTTP request to the hub API. -// Returns the response (caller must close body) or an error. -func (h *HubService) doRequest(method, path string, body interface{}) (*http.Response, error) { - hubURL := h.config.GetHubURL() - if hubURL == "" { - return nil, fmt.Errorf("hub URL not configured") - } - - fullURL := hubURL + "/api/bugseti" + path - - var bodyReader io.Reader - if body != nil { - data, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - bodyReader = bytes.NewReader(data) - } - - req, err := http.NewRequest(method, fullURL, bodyReader) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - if token := h.config.GetHubToken(); token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - - resp, err := h.httpClient.Do(req) - if err != nil { - h.mu.Lock() - h.connected = false - h.mu.Unlock() - return nil, fmt.Errorf("hub request failed: %w", err) - } - - h.mu.Lock() - h.connected = true - h.mu.Unlock() - - return resp, nil -} - -// doJSON performs a request and decodes the JSON response into dest. -func (h *HubService) doJSON(method, path string, body interface{}, dest interface{}) error { - resp, err := h.doRequest(method, path, body) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode == 401 { - return fmt.Errorf("unauthorised") - } - if resp.StatusCode == 409 { - return &ConflictError{StatusCode: resp.StatusCode} - } - if resp.StatusCode == 404 { - return &NotFoundError{StatusCode: resp.StatusCode} - } - if resp.StatusCode >= 400 { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("hub error %d: %s", resp.StatusCode, string(bodyBytes)) - } - - if dest != nil { - if err := json.NewDecoder(resp.Body).Decode(dest); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - } - - return nil -} - -// ConflictError indicates a 409 response (e.g. issue already claimed). -type ConflictError struct { - StatusCode int -} - -func (e *ConflictError) Error() string { - return fmt.Sprintf("conflict (HTTP %d)", e.StatusCode) -} - -// NotFoundError indicates a 404 response. -type NotFoundError struct { - StatusCode int -} - -func (e *NotFoundError) Error() string { - return fmt.Sprintf("not found (HTTP %d)", e.StatusCode) -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/bugseti/... -run TestDoRequest -count=1` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/bugseti/hub.go internal/bugseti/hub_test.go -git commit -m "feat(bugseti): add hub HTTP request helpers with error types" -``` - ---- - -### Task 4: Auto-Register via Forge Token - -Implement the auth flow: send forge token to portal, receive `ak_` token. - -**Files:** -- Modify: `internal/bugseti/hub.go` -- Modify: `internal/bugseti/hub_test.go` - -**Step 1: Write failing tests** - -Add to `hub_test.go`: - -```go -func TestAutoRegister_Good(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/bugseti/auth/forge" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if r.Method != "POST" { - t.Fatalf("expected POST, got %s", r.Method) - } - - var body map[string]string - json.NewDecoder(r.Body).Decode(&body) - - if body["forge_url"] == "" || body["forge_token"] == "" { - w.WriteHeader(400) - return - } - - w.WriteHeader(201) - json.NewEncoder(w).Encode(map[string]string{ - "api_key": "ak_test123456789012345678901234", - }) - })) - defer server.Close() - - cfg := testConfigService(t, nil, nil) - cfg.config.HubURL = server.URL - cfg.config.ForgeURL = "https://forge.lthn.io" - cfg.config.ForgeToken = "forge-test-token" - h := NewHubService(cfg) - - err := h.AutoRegister() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cfg.GetHubToken() != "ak_test123456789012345678901234" { - t.Fatalf("expected token to be cached, got %q", cfg.GetHubToken()) - } -} - -func TestAutoRegister_Bad_NoForgeToken(t *testing.T) { - cfg := testConfigService(t, nil, nil) - cfg.config.HubURL = "http://localhost" - h := NewHubService(cfg) - - err := h.AutoRegister() - if err == nil { - t.Fatal("expected error when forge token is missing") - } -} - -func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) { - cfg := testConfigService(t, nil, nil) - cfg.config.HubToken = "ak_existing_token" - h := NewHubService(cfg) - - err := h.AutoRegister() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // Token should remain unchanged - if cfg.GetHubToken() != "ak_existing_token" { - t.Fatal("existing token should not be overwritten") - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/bugseti/... -run TestAutoRegister -count=1` -Expected: FAIL — `AutoRegister` not defined. - -**Step 3: Implement AutoRegister** - -Add to `hub.go`: - -```go -// AutoRegister exchanges forge credentials for a hub API key. -// Skips if a token is already cached. On 401, clears cached token. -func (h *HubService) AutoRegister() error { - // Skip if already registered - if h.config.GetHubToken() != "" { - return nil - } - - hubURL := h.config.GetHubURL() - if hubURL == "" { - return fmt.Errorf("hub URL not configured") - } - - forgeURL := h.config.GetForgeURL() - forgeToken := h.config.GetForgeToken() - - // Fall back to pkg/forge config resolution - if forgeURL == "" || forgeToken == "" { - resolvedURL, resolvedToken, err := resolveForgeConfig(forgeURL, forgeToken) - if err != nil { - return fmt.Errorf("failed to resolve forge config: %w", err) - } - forgeURL = resolvedURL - forgeToken = resolvedToken - } - - if forgeToken == "" { - return fmt.Errorf("forge token not configured — cannot auto-register with hub") - } - - body := map[string]string{ - "forge_url": forgeURL, - "forge_token": forgeToken, - "client_id": h.GetClientID(), - } - - var result struct { - APIKey string `json:"api_key"` - } - - data, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("failed to marshal register body: %w", err) - } - - resp, err := h.httpClient.Post( - hubURL+"/api/bugseti/auth/forge", - "application/json", - bytes.NewReader(data), - ) - if err != nil { - return fmt.Errorf("hub auto-register failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 201 && resp.StatusCode != 200 { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("hub auto-register failed (HTTP %d): %s", resp.StatusCode, string(bodyBytes)) - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return fmt.Errorf("failed to decode register response: %w", err) - } - - if result.APIKey == "" { - return fmt.Errorf("hub returned empty API key") - } - - // Cache the token - if err := h.config.SetHubToken(result.APIKey); err != nil { - return fmt.Errorf("failed to cache hub token: %w", err) - } - - log.Printf("Hub: registered with portal, token cached") - return nil -} - -// resolveForgeConfig gets forge URL/token from pkg/forge config chain. -func resolveForgeConfig(flagURL, flagToken string) (string, string, error) { - // Import forge package for config resolution - // This uses the same resolution chain: config.yaml → env vars → flags - forgeURL, forgeToken, err := forgeResolveConfig(flagURL, flagToken) - if err != nil { - return "", "", err - } - return forgeURL, forgeToken, nil -} -``` - -Note: `resolveForgeConfig` wraps `forge.ResolveConfig` — we'll use the import directly in the real code. For the plan, this shows the intent. - -**Step 4: Run tests** - -Run: `go test ./internal/bugseti/... -run TestAutoRegister -count=1` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/bugseti/hub.go internal/bugseti/hub_test.go -git commit -m "feat(bugseti): hub auto-register via forge token" -``` - ---- - -### Task 5: Write Operations — Register, Heartbeat, Claim, Update, Release, SyncStats - -Implement all write API methods. - -**Files:** -- Modify: `internal/bugseti/hub.go` -- Modify: `internal/bugseti/hub_test.go` - -**Step 1: Write failing tests** - -Add to `hub_test.go`: - -```go -func TestRegister_Good(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/bugseti/register" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - var body map[string]string - json.NewDecoder(r.Body).Decode(&body) - if body["client_id"] == "" || body["name"] == "" { - w.WriteHeader(400) - return - } - w.WriteHeader(201) - json.NewEncoder(w).Encode(map[string]interface{}{"client": body}) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - h.config.config.ClientName = "Test Mac" - - err := h.Register() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestHeartbeat_Good(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - - err := h.Heartbeat() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestClaimIssue_Good(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(201) - json.NewEncoder(w).Encode(map[string]interface{}{ - "claim": map[string]interface{}{ - "issue_id": "owner/repo#42", - "status": "claimed", - }, - }) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - - claim, err := h.ClaimIssue(&Issue{ - ID: "owner/repo#42", Repo: "owner/repo", Number: 42, - Title: "Fix bug", URL: "https://forge.lthn.io/owner/repo/issues/42", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if claim == nil || claim.IssueID != "owner/repo#42" { - t.Fatal("expected claim with correct issue ID") - } -} - -func TestClaimIssue_Bad_Conflict(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(409) - json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Issue already claimed", - }) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - - _, err := h.ClaimIssue(&Issue{ID: "owner/repo#42", Repo: "owner/repo", Number: 42}) - if err == nil { - t.Fatal("expected conflict error") - } - if _, ok := err.(*ConflictError); !ok { - t.Fatalf("expected ConflictError, got %T", err) - } -} - -func TestUpdateStatus_Good(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "PATCH" { - t.Fatalf("expected PATCH, got %s", r.Method) - } - w.WriteHeader(200) - json.NewEncoder(w).Encode(map[string]interface{}{"claim": map[string]string{"status": "completed"}}) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - - err := h.UpdateStatus("owner/repo#42", "completed", "https://forge.lthn.io/pr/1", 1) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestSyncStats_Good(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - json.NewEncoder(w).Encode(map[string]interface{}{"synced": true}) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - - err := h.SyncStats(&Stats{ - IssuesCompleted: 5, - PRsSubmitted: 3, - PRsMerged: 2, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/bugseti/... -run "TestRegister_Good|TestHeartbeat|TestClaimIssue|TestUpdateStatus|TestSyncStats" -count=1` -Expected: FAIL - -**Step 3: Implement write methods** - -Add to `hub.go`: - -```go -// Register sends client registration to the hub portal. -func (h *HubService) Register() error { - h.drainPendingOps() - - name := h.config.GetClientName() - if name == "" { - name = fmt.Sprintf("BugSETI-%s", h.GetClientID()[:8]) - } - - body := map[string]string{ - "client_id": h.GetClientID(), - "name": name, - "version": GetVersion(), - "os": runtime.GOOS, - "arch": runtime.GOARCH, - } - - return h.doJSON("POST", "/register", body, nil) -} - -// Heartbeat sends a heartbeat to the hub portal. -func (h *HubService) Heartbeat() error { - body := map[string]string{ - "client_id": h.GetClientID(), - } - return h.doJSON("POST", "/heartbeat", body, nil) -} - -// ClaimIssue claims an issue on the hub portal. -// Returns the claim on success, ConflictError if already claimed. -func (h *HubService) ClaimIssue(issue *Issue) (*HubClaim, error) { - if issue == nil { - return nil, fmt.Errorf("issue is nil") - } - - h.drainPendingOps() - - body := map[string]interface{}{ - "client_id": h.GetClientID(), - "issue_id": issue.ID, - "repo": issue.Repo, - "issue_number": issue.Number, - "title": issue.Title, - "url": issue.URL, - } - - var result struct { - Claim *HubClaim `json:"claim"` - } - - if err := h.doJSON("POST", "/issues/claim", body, &result); err != nil { - return nil, err - } - - return result.Claim, nil -} - -// UpdateStatus updates the status of a claimed issue. -func (h *HubService) UpdateStatus(issueID, status, prURL string, prNumber int) error { - body := map[string]interface{}{ - "client_id": h.GetClientID(), - "status": status, - } - if prURL != "" { - body["pr_url"] = prURL - body["pr_number"] = prNumber - } - - encodedID := url.PathEscape(issueID) - return h.doJSON("PATCH", "/issues/"+encodedID+"/status", body, nil) -} - -// ReleaseClaim releases a claim on an issue. -func (h *HubService) ReleaseClaim(issueID string) error { - body := map[string]string{ - "client_id": h.GetClientID(), - } - - encodedID := url.PathEscape(issueID) - return h.doJSON("DELETE", "/issues/"+encodedID+"/claim", body, nil) -} - -// SyncStats uploads local stats to the hub portal. -func (h *HubService) SyncStats(stats *Stats) error { - if stats == nil { - return fmt.Errorf("stats is nil") - } - - repoNames := make([]string, 0, len(stats.ReposContributed)) - for name := range stats.ReposContributed { - repoNames = append(repoNames, name) - } - - body := map[string]interface{}{ - "client_id": h.GetClientID(), - "stats": map[string]interface{}{ - "issues_attempted": stats.IssuesAttempted, - "issues_completed": stats.IssuesCompleted, - "issues_skipped": stats.IssuesSkipped, - "prs_submitted": stats.PRsSubmitted, - "prs_merged": stats.PRsMerged, - "prs_rejected": stats.PRsRejected, - "current_streak": stats.CurrentStreak, - "longest_streak": stats.LongestStreak, - "total_time_minutes": int(stats.TotalTimeSpent.Minutes()), - "repos_contributed": repoNames, - }, - } - - return h.doJSON("POST", "/stats/sync", body, nil) -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/bugseti/... -run "TestRegister_Good|TestHeartbeat|TestClaimIssue|TestUpdateStatus|TestSyncStats" -count=1` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/bugseti/hub.go internal/bugseti/hub_test.go -git commit -m "feat(bugseti): hub write operations (register, heartbeat, claim, update, sync)" -``` - ---- - -### Task 6: Read Operations — IsIssueClaimed, ListClaims, GetLeaderboard, GetGlobalStats - -**Files:** -- Modify: `internal/bugseti/hub.go` -- Modify: `internal/bugseti/hub_test.go` - -**Step 1: Write failing tests** - -Add to `hub_test.go`: - -```go -func TestIsIssueClaimed_Good_Claimed(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - json.NewEncoder(w).Encode(map[string]interface{}{ - "claim": map[string]interface{}{"issue_id": "o/r#1", "status": "claimed"}, - }) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - - claim, err := h.IsIssueClaimed("o/r#1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if claim == nil { - t.Fatal("expected claim") - } -} - -func TestIsIssueClaimed_Good_NotClaimed(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - - claim, err := h.IsIssueClaimed("o/r#1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if claim != nil { - t.Fatal("expected nil claim for unclaimed issue") - } -} - -func TestGetLeaderboard_Good(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("limit") != "10" { - t.Fatalf("expected limit=10, got %s", r.URL.Query().Get("limit")) - } - w.WriteHeader(200) - json.NewEncoder(w).Encode(map[string]interface{}{ - "leaderboard": []map[string]interface{}{{"rank": 1, "client_name": "Alice"}}, - "total_participants": 5, - }) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - - entries, total, err := h.GetLeaderboard(10) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(entries) != 1 || total != 5 { - t.Fatalf("expected 1 entry, 5 total; got %d, %d", len(entries), total) - } -} - -func TestGetGlobalStats_Good(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - json.NewEncoder(w).Encode(map[string]interface{}{ - "global": map[string]interface{}{ - "total_participants": 11, - "active_claims": 3, - }, - }) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - - stats, err := h.GetGlobalStats() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if stats.TotalParticipants != 11 { - t.Fatalf("expected 11 participants, got %d", stats.TotalParticipants) - } -} -``` - -**Step 2: Run to verify failure, then implement** - -Add to `hub.go`: - -```go -// IsIssueClaimed checks if an issue is claimed on the hub. -// Returns the claim if found, nil if not claimed. -func (h *HubService) IsIssueClaimed(issueID string) (*HubClaim, error) { - var result struct { - Claim *HubClaim `json:"claim"` - } - - encodedID := url.PathEscape(issueID) - err := h.doJSON("GET", "/issues/"+encodedID, nil, &result) - if err != nil { - if _, ok := err.(*NotFoundError); ok { - return nil, nil // Not claimed - } - return nil, err - } - - return result.Claim, nil -} - -// ListClaims returns active claims from the hub, with optional filters. -func (h *HubService) ListClaims(status, repo string) ([]*HubClaim, error) { - path := "/issues/claimed" - params := url.Values{} - if status != "" { - params.Set("status", status) - } - if repo != "" { - params.Set("repo", repo) - } - if len(params) > 0 { - path += "?" + params.Encode() - } - - var result struct { - Claims []*HubClaim `json:"claims"` - } - - if err := h.doJSON("GET", path, nil, &result); err != nil { - return nil, err - } - - return result.Claims, nil -} - -// GetLeaderboard returns the leaderboard from the hub portal. -func (h *HubService) GetLeaderboard(limit int) ([]LeaderboardEntry, int, error) { - if limit <= 0 { - limit = 20 - } - - path := fmt.Sprintf("/leaderboard?limit=%d", limit) - - var result struct { - Leaderboard []LeaderboardEntry `json:"leaderboard"` - TotalParticipants int `json:"total_participants"` - } - - if err := h.doJSON("GET", path, nil, &result); err != nil { - return nil, 0, err - } - - return result.Leaderboard, result.TotalParticipants, nil -} - -// GetGlobalStats returns aggregate stats from the hub portal. -func (h *HubService) GetGlobalStats() (*GlobalStats, error) { - var result struct { - Global *GlobalStats `json:"global"` - } - - if err := h.doJSON("GET", "/stats", nil, &result); err != nil { - return nil, err - } - - return result.Global, nil -} -``` - -**Step 3: Run tests** - -Run: `go test ./internal/bugseti/... -run "TestIsIssueClaimed|TestGetLeaderboard|TestGetGlobalStats" -count=1` -Expected: PASS - -**Step 4: Commit** - -```bash -git add internal/bugseti/hub.go internal/bugseti/hub_test.go -git commit -m "feat(bugseti): hub read operations (claims, leaderboard, global stats)" -``` - ---- - -### Task 7: Pending Operations Queue - -Implement offline-first: queue failed writes, persist to disk, drain on reconnect. - -**Files:** -- Modify: `internal/bugseti/hub.go` -- Modify: `internal/bugseti/hub_test.go` - -**Step 1: Write failing tests** - -Add to `hub_test.go`: - -```go -func TestPendingOps_Good_QueueAndDrain(t *testing.T) { - callCount := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - if r.URL.Path == "/api/bugseti/register" { - // First register drains pending ops — the heartbeat will come first - w.WriteHeader(200) - json.NewEncoder(w).Encode(map[string]interface{}{"client": nil}) - return - } - w.WriteHeader(200) - json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) - })) - defer server.Close() - - h := testHubService(t, server.URL) - h.config.config.HubToken = "ak_test" - - // Manually add a pending op - h.mu.Lock() - h.pendingOps = append(h.pendingOps, PendingOp{ - Method: "POST", - Path: "/heartbeat", - Body: []byte(`{"client_id":"test"}`), - CreatedAt: time.Now(), - }) - h.mu.Unlock() - - // Register should drain the pending heartbeat first - err := h.Register() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if callCount < 2 { - t.Fatalf("expected at least 2 calls (drain + register), got %d", callCount) - } -} - -func TestPendingOps_Good_PersistAndLoad(t *testing.T) { - cfg := testConfigService(t, nil, nil) - h1 := NewHubService(cfg) - - // Add pending op - h1.mu.Lock() - h1.pendingOps = append(h1.pendingOps, PendingOp{ - Method: "POST", - Path: "/heartbeat", - Body: []byte(`{"test":true}`), - CreatedAt: time.Now(), - }) - h1.mu.Unlock() - h1.savePendingOps() - - // Create new service — should load persisted ops - h2 := NewHubService(cfg) - h2.mu.Lock() - count := len(h2.pendingOps) - h2.mu.Unlock() - - if count != 1 { - t.Fatalf("expected 1 pending op after reload, got %d", count) - } -} -``` - -**Step 2: Implement pending ops** - -Add to `hub.go`: - -```go -// queueOp adds a failed write to the pending queue. -func (h *HubService) queueOp(method, path string, body interface{}) { - data, _ := json.Marshal(body) - h.mu.Lock() - h.pendingOps = append(h.pendingOps, PendingOp{ - Method: method, - Path: path, - Body: data, - CreatedAt: time.Now(), - }) - h.mu.Unlock() - h.savePendingOps() -} - -// drainPendingOps replays queued operations. Called before write methods. -func (h *HubService) drainPendingOps() { - h.mu.Lock() - ops := h.pendingOps - h.pendingOps = nil - h.mu.Unlock() - - if len(ops) == 0 { - return - } - - log.Printf("Hub: draining %d pending operations", len(ops)) - var failed []PendingOp - - for _, op := range ops { - resp, err := h.doRequest(op.Method, op.Path, json.RawMessage(op.Body)) - if err != nil { - failed = append(failed, op) - continue - } - resp.Body.Close() - if resp.StatusCode >= 500 { - failed = append(failed, op) - } - // 4xx errors are dropped (stale data) - } - - if len(failed) > 0 { - h.mu.Lock() - h.pendingOps = append(failed, h.pendingOps...) - h.mu.Unlock() - } - - h.savePendingOps() -} - -// savePendingOps persists the pending queue to disk. -func (h *HubService) savePendingOps() { - dataDir := h.config.GetDataDir() - if dataDir == "" { - return - } - - h.mu.Lock() - ops := h.pendingOps - h.mu.Unlock() - - data, err := json.Marshal(ops) - if err != nil { - return - } - - path := filepath.Join(dataDir, "hub_pending.json") - os.WriteFile(path, data, 0600) -} - -// loadPendingOps loads persisted pending operations from disk. -func (h *HubService) loadPendingOps() { - dataDir := h.config.GetDataDir() - if dataDir == "" { - return - } - - path := filepath.Join(dataDir, "hub_pending.json") - data, err := os.ReadFile(path) - if err != nil { - return - } - - var ops []PendingOp - if err := json.Unmarshal(data, &ops); err != nil { - return - } - - h.mu.Lock() - h.pendingOps = ops - h.mu.Unlock() -} - -// PendingCount returns the number of queued operations. -func (h *HubService) PendingCount() int { - h.mu.Lock() - defer h.mu.Unlock() - return len(h.pendingOps) -} -``` - -Also add `"os"` and `"path/filepath"` to the imports in `hub.go`. - -**Step 3: Run tests** - -Run: `go test ./internal/bugseti/... -run TestPendingOps -count=1` -Expected: PASS - -**Step 4: Commit** - -```bash -git add internal/bugseti/hub.go internal/bugseti/hub_test.go -git commit -m "feat(bugseti): hub pending operations queue with disk persistence" -``` - ---- - -### Task 8: Integration — main.go and Wails Registration - -Wire HubService into the app lifecycle. - -**Files:** -- Modify: `cmd/bugseti/main.go` - -**Step 1: Create HubService in main.go** - -After the `submitService` creation, add: - -```go -hubService := bugseti.NewHubService(configService) -``` - -Add to the services slice: - -```go -application.NewService(hubService), -``` - -After `log.Println("Starting BugSETI...")`, add: - -```go -// Attempt hub registration (non-blocking, logs warnings on failure) -if hubURL := configService.GetHubURL(); hubURL != "" { - if err := hubService.AutoRegister(); err != nil { - log.Printf("Hub: auto-register skipped: %v", err) - } else if err := hubService.Register(); err != nil { - log.Printf("Hub: registration failed: %v", err) - } -} -``` - -**Step 2: Build and verify** - -Run: `task bugseti:build` -Expected: Builds successfully. - -Run: `go test ./internal/bugseti/... -count=1` -Expected: All tests pass. - -**Step 3: Commit** - -```bash -git add cmd/bugseti/main.go -git commit -m "feat(bugseti): wire HubService into app lifecycle" -``` - ---- - -### Task 9: Laravel Auth/Forge Endpoint - -Create the portal-side endpoint that exchanges a forge token for an `ak_` API key. - -**Files:** -- Create: `agentic/app/Mod/BugSeti/Controllers/AuthController.php` -- Modify: `agentic/app/Mod/BugSeti/Routes/api.php` - -**Step 1: Create AuthController** - -Create `agentic/app/Mod/BugSeti/Controllers/AuthController.php`: - -```php -validate([ - 'forge_url' => 'required|url|max:500', - 'forge_token' => 'required|string|max:255', - 'client_id' => 'required|string|max:64', - ]); - - // Validate the forge token against the Forgejo API - $response = Http::withToken($validated['forge_token']) - ->timeout(10) - ->get(rtrim($validated['forge_url'], '/') . '/api/v1/user'); - - if (! $response->ok()) { - return response()->json([ - 'error' => 'Invalid Forgejo token — could not verify identity.', - ], 401); - } - - $forgeUser = $response->json(); - $forgeName = $forgeUser['full_name'] ?: $forgeUser['login'] ?? 'Unknown'; - - // Find or create workspace for BugSETI clients - $workspace = Workspace::firstOrCreate( - ['slug' => 'bugseti-community'], - ['name' => 'BugSETI Community', 'owner_id' => null] - ); - - // Check if this client already has a key - $existingKey = AgentApiKey::where('workspace_id', $workspace->id) - ->where('name', 'like', '%' . $validated['client_id'] . '%') - ->whereNull('revoked_at') - ->first(); - - if ($existingKey) { - // Revoke old key and issue new one - $existingKey->update(['revoked_at' => now()]); - } - - $apiKey = AgentApiKey::generate( - workspace: $workspace->id, - name: "BugSETI — {$forgeName} ({$validated['client_id']})", - permissions: ['bugseti.read', 'bugseti.write'], - rateLimit: 100, - expiresAt: null, - ); - - return response()->json([ - 'api_key' => $apiKey->plainTextKey, - 'forge_user' => $forgeName, - ], 201); - } -} -``` - -**Step 2: Add route** - -In `agentic/app/Mod/BugSeti/Routes/api.php`, add **outside** the authenticated groups: - -```php -// Unauthenticated bootstrap — exchanges forge token for API key -Route::post('/auth/forge', [AuthController::class, 'forge']); -``` - -Add the use statement at top of file: - -```php -use Mod\BugSeti\Controllers\AuthController; -``` - -**Step 3: Test manually** - -```bash -cd /Users/snider/Code/host-uk/agentic -php artisan migrate -curl -X POST http://leth.test/api/bugseti/auth/forge \ - -H "Content-Type: application/json" \ - -d '{"forge_url":"https://forge.lthn.io","forge_token":"500ecb79c79da940205f37580438575dbf7a82be","client_id":"test-client-1"}' -``` - -Expected: 201 with `{"api_key":"ak_...","forge_user":"..."}`. - -**Step 4: Commit** - -```bash -cd /Users/snider/Code/host-uk/agentic -git add app/Mod/BugSeti/Controllers/AuthController.php app/Mod/BugSeti/Routes/api.php -git commit -m "feat(bugseti): add /auth/forge endpoint for token exchange" -``` - ---- - -### Task 10: Full Integration Test - -Build the binary, configure hub URL, and verify end-to-end. - -**Files:** None (verification only) - -**Step 1: Run all Go tests** - -```bash -cd /Users/snider/Code/host-uk/core -go test ./internal/bugseti/... -count=1 -v -``` - -Expected: All tests pass. - -**Step 2: Build binary** - -```bash -task bugseti:build -``` - -Expected: Binary builds at `bin/bugseti`. - -**Step 3: Configure hub URL and test launch** - -```bash -# Set hub URL to devnet -cat ~/.config/bugseti/config.json | python3 -c " -import json,sys -c = json.load(sys.stdin) -c['hubUrl'] = 'https://leth.in' -json.dump(c, sys.stdout, indent=2) -" > /tmp/bugseti-config.json && mv /tmp/bugseti-config.json ~/.config/bugseti/config.json -``` - -Launch `./bin/bugseti` — should start without errors, attempt hub registration. - -**Step 4: Final commit if needed** - -```bash -git add -A && git commit -m "feat(bugseti): HubService integration complete" -``` - ---- - -### Summary - -| Task | Description | Files | -|------|-------------|-------| -| 1 | Config fields | config.go | -| 2 | HubService types + constructor | hub.go, hub_test.go | -| 3 | HTTP request helpers | hub.go, hub_test.go | -| 4 | Auto-register via forge | hub.go, hub_test.go | -| 5 | Write operations | hub.go, hub_test.go | -| 6 | Read operations | hub.go, hub_test.go | -| 7 | Pending ops queue | hub.go, hub_test.go | -| 8 | main.go integration | main.go | -| 9 | Laravel auth/forge endpoint | AuthController.php, api.php | -| 10 | Full integration test | (verification) | diff --git a/pkg/i18n/internal/validate/main.go b/pkg/i18n/internal/validate/main.go new file mode 100644 index 0000000..5e0d942 --- /dev/null +++ b/pkg/i18n/internal/validate/main.go @@ -0,0 +1,525 @@ +// Command i18n-validate scans Go source files for i18n key usage and validates +// them against the locale JSON files. +// +// Usage: +// +// go run ./cmd/i18n-validate ./... +// go run ./cmd/i18n-validate ./pkg/cli ./cmd/dev +// +// The validator checks: +// - T("key") calls - validates key exists in locale files +// - C("intent", ...) calls - validates intent exists in registered intents +// - i18n.T("key") and i18n.C("intent", ...) qualified calls +// +// Exit codes: +// - 0: All keys valid +// - 1: Missing keys found +// - 2: Error during validation +package main + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" +) + +// KeyUsage records where a key is used in the source code. +type KeyUsage struct { + Key string + File string + Line int + Function string // "T" or "C" +} + +// ValidationResult holds the results of validation. +type ValidationResult struct { + TotalKeys int + ValidKeys int + MissingKeys []KeyUsage + IntentKeys int + MessageKeys int +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: i18n-validate ") + fmt.Fprintln(os.Stderr, "Example: i18n-validate ./...") + os.Exit(2) + } + + // Find the project root (where locales are) + root, err := findProjectRoot() + if err != nil { + fmt.Fprintf(os.Stderr, "Error finding project root: %v\n", err) + os.Exit(2) + } + + // Load valid keys from locale files + validKeys, err := loadValidKeys(filepath.Join(root, "pkg/i18n/locales")) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading locale files: %v\n", err) + os.Exit(2) + } + + // Load valid intents + validIntents := loadValidIntents() + + // Scan source files + usages, err := scanPackages(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error scanning packages: %v\n", err) + os.Exit(2) + } + + // Validate + result := validate(usages, validKeys, validIntents) + + // Report + printReport(result) + + if len(result.MissingKeys) > 0 { + os.Exit(1) + } +} + +// findProjectRoot finds the project root by looking for go.mod. +func findProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("could not find go.mod in any parent directory") + } + dir = parent + } +} + +// loadValidKeys loads all valid keys from locale JSON files. +func loadValidKeys(localesDir string) (map[string]bool, error) { + keys := make(map[string]bool) + + entries, err := os.ReadDir(localesDir) + if err != nil { + return nil, fmt.Errorf("reading locales dir: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + data, err := os.ReadFile(filepath.Join(localesDir, entry.Name())) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", entry.Name(), err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err) + } + + extractKeys("", raw, keys) + } + + return keys, nil +} + +// extractKeys recursively extracts flattened keys from nested JSON. +func extractKeys(prefix string, data map[string]any, out map[string]bool) { + for key, value := range data { + fullKey := key + if prefix != "" { + fullKey = prefix + "." + key + } + + switch v := value.(type) { + case string: + out[fullKey] = true + case map[string]any: + // Check if it's a plural/verb/noun object (has specific keys) + if isPluralOrGrammarObject(v) { + out[fullKey] = true + } else { + extractKeys(fullKey, v, out) + } + } + } +} + +// isPluralOrGrammarObject checks if a map is a leaf object (plural forms, verb forms, etc). +func isPluralOrGrammarObject(m map[string]any) bool { + // CLDR plural keys + _, hasOne := m["one"] + _, hasOther := m["other"] + _, hasZero := m["zero"] + _, hasTwo := m["two"] + _, hasFew := m["few"] + _, hasMany := m["many"] + + // Grammar keys + _, hasPast := m["past"] + _, hasGerund := m["gerund"] + _, hasGender := m["gender"] + _, hasBase := m["base"] + + // Article keys + _, hasDefault := m["default"] + _, hasVowel := m["vowel"] + + if hasOne || hasOther || hasZero || hasTwo || hasFew || hasMany { + return true + } + if hasPast || hasGerund || hasGender || hasBase { + return true + } + if hasDefault || hasVowel { + return true + } + + return false +} + +// loadValidIntents returns the set of valid intent keys. +func loadValidIntents() map[string]bool { + // Core intents - these match what's defined in intents.go + return map[string]bool{ + // Destructive + "core.delete": true, + "core.remove": true, + "core.discard": true, + "core.reset": true, + "core.overwrite": true, + // Creation + "core.create": true, + "core.add": true, + "core.clone": true, + "core.copy": true, + // Modification + "core.save": true, + "core.update": true, + "core.rename": true, + "core.move": true, + // Git + "core.commit": true, + "core.push": true, + "core.pull": true, + "core.merge": true, + "core.rebase": true, + // Network + "core.install": true, + "core.download": true, + "core.upload": true, + "core.publish": true, + "core.deploy": true, + // Process + "core.start": true, + "core.stop": true, + "core.restart": true, + "core.run": true, + "core.build": true, + "core.test": true, + // Information + "core.continue": true, + "core.proceed": true, + "core.confirm": true, + // Additional + "core.sync": true, + "core.boot": true, + "core.format": true, + "core.analyse": true, + "core.link": true, + "core.unlink": true, + "core.fetch": true, + "core.generate": true, + "core.validate": true, + "core.check": true, + "core.scan": true, + } +} + +// scanPackages scans Go packages for i18n key usage. +func scanPackages(patterns []string) ([]KeyUsage, error) { + var usages []KeyUsage + + for _, pattern := range patterns { + // Expand pattern + matches, err := expandPattern(pattern) + if err != nil { + return nil, fmt.Errorf("expanding pattern %q: %w", pattern, err) + } + + for _, dir := range matches { + dirUsages, err := scanDirectory(dir) + if err != nil { + return nil, fmt.Errorf("scanning %s: %w", dir, err) + } + usages = append(usages, dirUsages...) + } + } + + return usages, nil +} + +// expandPattern expands a Go package pattern to directories. +func expandPattern(pattern string) ([]string, error) { + // Handle ./... or ... pattern + if strings.HasSuffix(pattern, "...") { + base := strings.TrimSuffix(pattern, "...") + base = strings.TrimSuffix(base, "/") + if base == "" || base == "." { + base = "." + } + return findAllGoDirs(base) + } + + // Single directory + return []string{pattern}, nil +} + +// findAllGoDirs finds all directories containing .go files. +func findAllGoDirs(root string) ([]string, error) { + var dirs []string + seen := make(map[string]bool) + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Continue walking even on error + } + + if info == nil { + return nil + } + + // Skip vendor, testdata, and hidden directories (but not . itself) + if info.IsDir() { + name := info.Name() + if name == "vendor" || name == "testdata" || (strings.HasPrefix(name, ".") && name != ".") { + return filepath.SkipDir + } + return nil + } + + // Check for .go files + if strings.HasSuffix(path, ".go") { + dir := filepath.Dir(path) + if !seen[dir] { + seen[dir] = true + dirs = append(dirs, dir) + } + } + + return nil + }) + + return dirs, err +} + +// scanDirectory scans a directory for i18n key usage. +func scanDirectory(dir string) ([]KeyUsage, error) { + var usages []KeyUsage + + fset := token.NewFileSet() + // Parse all .go files except those ending exactly in _test.go + pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { + name := fi.Name() + // Only exclude files that are actual test files (ending in _test.go) + // Files like "go_test_cmd.go" should be included + return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") + }, 0) + if err != nil { + return nil, err + } + + for _, pkg := range pkgs { + for filename, file := range pkg.Files { + fileUsages := scanFile(fset, filename, file) + usages = append(usages, fileUsages...) + } + } + + return usages, nil +} + +// scanFile scans a single file for i18n key usage. +func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage { + var usages []KeyUsage + + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + + funcName := getFuncName(call) + if funcName == "" { + return true + } + + // Check for T(), C(), i18n.T(), i18n.C() + switch funcName { + case "T", "i18n.T", "_", "i18n._": + if key := extractStringArg(call, 0); key != "" { + pos := fset.Position(call.Pos()) + usages = append(usages, KeyUsage{ + Key: key, + File: filename, + Line: pos.Line, + Function: "T", + }) + } + case "C", "i18n.C": + if key := extractStringArg(call, 0); key != "" { + pos := fset.Position(call.Pos()) + usages = append(usages, KeyUsage{ + Key: key, + File: filename, + Line: pos.Line, + Function: "C", + }) + } + case "I", "i18n.I": + if key := extractStringArg(call, 0); key != "" { + pos := fset.Position(call.Pos()) + usages = append(usages, KeyUsage{ + Key: key, + File: filename, + Line: pos.Line, + Function: "C", // I() is an intent builder + }) + } + } + + return true + }) + + return usages +} + +// getFuncName extracts the function name from a call expression. +func getFuncName(call *ast.CallExpr) string { + switch fn := call.Fun.(type) { + case *ast.Ident: + return fn.Name + case *ast.SelectorExpr: + if ident, ok := fn.X.(*ast.Ident); ok { + return ident.Name + "." + fn.Sel.Name + } + } + return "" +} + +// extractStringArg extracts a string literal from a call argument. +func extractStringArg(call *ast.CallExpr, index int) string { + if index >= len(call.Args) { + return "" + } + + arg := call.Args[index] + + // Direct string literal + if lit, ok := arg.(*ast.BasicLit); ok && lit.Kind == token.STRING { + // Remove quotes + s := lit.Value + if len(s) >= 2 { + return s[1 : len(s)-1] + } + } + + // Identifier (constant reference) - we skip these as they're type-safe + if _, ok := arg.(*ast.Ident); ok { + return "" // Skip constants like IntentCoreDelete + } + + // Selector (like i18n.IntentCoreDelete) - skip these too + if _, ok := arg.(*ast.SelectorExpr); ok { + return "" + } + + return "" +} + +// validate validates key usages against valid keys and intents. +func validate(usages []KeyUsage, validKeys, validIntents map[string]bool) ValidationResult { + result := ValidationResult{ + TotalKeys: len(usages), + } + + for _, usage := range usages { + if usage.Function == "C" { + result.IntentKeys++ + // Check intent keys + if validIntents[usage.Key] { + result.ValidKeys++ + } else { + // Also allow custom intents (non-core.* prefix) + if !strings.HasPrefix(usage.Key, "core.") { + result.ValidKeys++ // Assume custom intents are valid + } else { + result.MissingKeys = append(result.MissingKeys, usage) + } + } + } else { + result.MessageKeys++ + // Check message keys + if validKeys[usage.Key] { + result.ValidKeys++ + } else if strings.HasPrefix(usage.Key, "core.") { + // core.* keys used with T() are intent keys + if validIntents[usage.Key] { + result.ValidKeys++ + } else { + result.MissingKeys = append(result.MissingKeys, usage) + } + } else { + result.MissingKeys = append(result.MissingKeys, usage) + } + } + } + + return result +} + +// printReport prints the validation report. +func printReport(result ValidationResult) { + fmt.Printf("i18n Validation Report\n") + fmt.Printf("======================\n\n") + fmt.Printf("Total keys scanned: %d\n", result.TotalKeys) + fmt.Printf(" Message keys (T): %d\n", result.MessageKeys) + fmt.Printf(" Intent keys (C): %d\n", result.IntentKeys) + fmt.Printf("Valid keys: %d\n", result.ValidKeys) + fmt.Printf("Missing keys: %d\n", len(result.MissingKeys)) + + if len(result.MissingKeys) > 0 { + fmt.Printf("\nMissing Keys:\n") + fmt.Printf("-------------\n") + + // Sort by file then line + sort.Slice(result.MissingKeys, func(i, j int) bool { + if result.MissingKeys[i].File != result.MissingKeys[j].File { + return result.MissingKeys[i].File < result.MissingKeys[j].File + } + return result.MissingKeys[i].Line < result.MissingKeys[j].Line + }) + + for _, usage := range result.MissingKeys { + fmt.Printf(" %s:%d: %s(%q)\n", usage.File, usage.Line, usage.Function, usage.Key) + } + + fmt.Printf("\nAdd these keys to pkg/i18n/locales/en_GB.json or use constants from pkg/i18n/keys.go\n") + } else { + fmt.Printf("\nAll keys are valid!\n") + } +}