From 4134c58488d5254902f3458a837192bee3874507 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Feb 2026 21:31:23 +0000 Subject: [PATCH] feat(bugseti): add AutoRegister via Forge token exchange Exchange a Forge API token for a hub API key by POSTing to /api/bugseti/auth/forge. Skips if hub token already cached. Adds drainPendingOps() stub for future Task 7 use. Co-Authored-By: Virgil --- internal/bugseti/hub.go | 80 ++++++++++++++++++++++++++++++++++++ internal/bugseti/hub_test.go | 66 +++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/internal/bugseti/hub.go b/internal/bugseti/hub.go index 7060b7c..09ed242 100644 --- a/internal/bugseti/hub.go +++ b/internal/bugseti/hub.go @@ -8,9 +8,12 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "sync" "time" + + "github.com/host-uk/core/pkg/forge" ) // HubService coordinates with the agentic portal for issue assignment and leaderboard. @@ -207,3 +210,80 @@ func (h *HubService) loadPendingOps() {} // savePendingOps is a no-op placeholder (disk persistence comes in Task 7). func (h *HubService) savePendingOps() {} + +// drainPendingOps replays queued operations (no-op until Task 7). +func (h *HubService) drainPendingOps() {} + +// AutoRegister exchanges a Forge API token for a hub API key. +// If a hub token is already configured, this is a no-op. +func (h *HubService) AutoRegister() error { + // Skip if already registered. + if h.config.GetHubToken() != "" { + return nil + } + + hubURL := h.config.GetHubURL() + if hubURL == "" { + return fmt.Errorf("hub URL not configured") + } + + // Resolve forge credentials from config/env. + forgeURL := h.config.GetForgeURL() + forgeToken := h.config.GetForgeToken() + if forgeToken == "" { + resolvedURL, resolvedToken, err := forge.ResolveConfig(forgeURL, "") + if err != nil { + return fmt.Errorf("resolve forge config: %w", err) + } + forgeURL = resolvedURL + forgeToken = resolvedToken + } + + if forgeToken == "" { + return fmt.Errorf("no forge token available (set FORGE_TOKEN or run: core forge config --token TOKEN)") + } + + // Build request body. + payload := map[string]string{ + "forge_url": forgeURL, + "forge_token": forgeToken, + "client_id": h.config.GetClientID(), + } + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal auto-register body: %w", err) + } + + // POST directly (no bearer token yet). + resp, err := h.client.Post(hubURL+"/api/bugseti/auth/forge", "application/json", bytes.NewReader(data)) + if err != nil { + h.mu.Lock() + h.connected = false + h.mu.Unlock() + return fmt.Errorf("auto-register request: %w", err) + } + defer resp.Body.Close() + + h.mu.Lock() + h.connected = true + h.mu.Unlock() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("auto-register failed %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + APIKey string `json:"api_key"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decode auto-register response: %w", err) + } + + if err := h.config.SetHubToken(result.APIKey); err != nil { + return fmt.Errorf("cache hub token: %w", err) + } + + log.Printf("BugSETI: auto-registered with hub, token cached") + return nil +} diff --git a/internal/bugseti/hub_test.go b/internal/bugseti/hub_test.go index 70b402a..80f49d0 100644 --- a/internal/bugseti/hub_test.go +++ b/internal/bugseti/hub_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" "github.com/stretchr/testify/assert" @@ -108,3 +109,68 @@ func TestDoRequest_Bad_NetworkError(t *testing.T) { assert.Error(t, err) assert.False(t, h.IsConnected()) } + +// ---- AutoRegister ---- + +func TestAutoRegister_Good(t *testing.T) { + var gotBody map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/bugseti/auth/forge", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + _ = json.NewDecoder(r.Body).Decode(&gotBody) + + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"api_key":"ak_test_12345"}`)) + })) + defer srv.Close() + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = srv.URL + cfg.config.ForgeURL = "https://forge.example.com" + cfg.config.ForgeToken = "forge-tok-abc" + h := NewHubService(cfg) + + err := h.AutoRegister() + require.NoError(t, err) + + // Verify token was cached. + assert.Equal(t, "ak_test_12345", h.config.GetHubToken()) + + // Verify request body. + assert.Equal(t, "https://forge.example.com", gotBody["forge_url"]) + assert.Equal(t, "forge-tok-abc", gotBody["forge_token"]) + assert.NotEmpty(t, gotBody["client_id"]) +} + +func TestAutoRegister_Bad_NoForgeToken(t *testing.T) { + // Isolate from user's real ~/.core/config.yaml and env vars. + origHome := os.Getenv("HOME") + t.Setenv("HOME", t.TempDir()) + t.Setenv("FORGE_TOKEN", "") + t.Setenv("FORGE_URL", "") + defer os.Setenv("HOME", origHome) + + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = "https://hub.example.com" + // No forge token set, and env/config are empty in test. + h := NewHubService(cfg) + + err := h.AutoRegister() + require.Error(t, err) + assert.Contains(t, err.Error(), "no forge token available") +} + +func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) { + cfg := testConfigService(t, nil, nil) + cfg.config.HubURL = "https://hub.example.com" + cfg.config.HubToken = "existing-token" + h := NewHubService(cfg) + + err := h.AutoRegister() + require.NoError(t, err) + + // Token should remain unchanged. + assert.Equal(t, "existing-token", h.config.GetHubToken()) +}