cli/docs/plans/2026-02-13-bugseti-hub-service-plan.md

1621 lines
40 KiB
Markdown
Raw Normal View History

# 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
<?php
declare(strict_types=1);
namespace Mod\BugSeti\Controllers;
use Core\Agentic\Models\AgentApiKey;
use Core\Agentic\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class AuthController
{
/**
* Exchange a Forgejo token for a BugSETI API key.
*
* POST /api/bugseti/auth/forge
* No authentication required (this IS the bootstrap).
*/
public function forge(Request $request): JsonResponse
{
$validated = $request->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) |