Merge pull request 'feat(bugseti): add HubService for portal coordination' (#160) from feat/bugseti-hub-service into new
This commit is contained in:
commit
1f43073f57
6 changed files with 3001 additions and 0 deletions
|
|
@ -52,6 +52,7 @@ func main() {
|
||||||
queueService := bugseti.NewQueueService(configService)
|
queueService := bugseti.NewQueueService(configService)
|
||||||
seederService := bugseti.NewSeederService(configService, forgeClient.URL(), forgeClient.Token())
|
seederService := bugseti.NewSeederService(configService, forgeClient.URL(), forgeClient.Token())
|
||||||
submitService := bugseti.NewSubmitService(configService, notifyService, statsService, forgeClient)
|
submitService := bugseti.NewSubmitService(configService, notifyService, statsService, forgeClient)
|
||||||
|
hubService := bugseti.NewHubService(configService)
|
||||||
versionService := bugseti.NewVersionService()
|
versionService := bugseti.NewVersionService()
|
||||||
workspaceService := NewWorkspaceService(configService)
|
workspaceService := NewWorkspaceService(configService)
|
||||||
|
|
||||||
|
|
@ -75,6 +76,7 @@ func main() {
|
||||||
application.NewService(submitService),
|
application.NewService(submitService),
|
||||||
application.NewService(versionService),
|
application.NewService(versionService),
|
||||||
application.NewService(workspaceService),
|
application.NewService(workspaceService),
|
||||||
|
application.NewService(hubService),
|
||||||
application.NewService(trayService),
|
application.NewService(trayService),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,6 +115,19 @@ func main() {
|
||||||
log.Println(" - Waiting for issues...")
|
log.Println(" - Waiting for issues...")
|
||||||
log.Printf(" - Version: %s (%s)", bugseti.GetVersion(), bugseti.GetChannel())
|
log.Printf(" - Version: %s (%s)", bugseti.GetVersion(), bugseti.GetChannel())
|
||||||
|
|
||||||
|
// Attempt hub registration (non-blocking)
|
||||||
|
if hubURL := configService.GetHubURL(); hubURL != "" {
|
||||||
|
if err := hubService.AutoRegister(); err != nil {
|
||||||
|
log.Printf(" - Hub: auto-register skipped: %v", err)
|
||||||
|
} else if err := hubService.Register(); err != nil {
|
||||||
|
log.Printf(" - Hub: registration failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println(" - Hub: registered with portal")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Println(" - Hub: not configured (set hubUrl in config)")
|
||||||
|
}
|
||||||
|
|
||||||
if err := app.Run(); err != nil {
|
if err := app.Run(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
150
docs/plans/2026-02-13-bugseti-hub-service-design.md
Normal file
150
docs/plans/2026-02-13-bugseti-hub-service-design.md
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
# BugSETI HubService Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A thin HTTP client service in the BugSETI desktop app that coordinates with the agentic portal's `/api/bugseti/*` endpoints. Prevents duplicate work across the 11 community testers, aggregates stats for leaderboard, and registers client instances.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Target | Direct to portal API | Endpoints built for this purpose |
|
||||||
|
| Auth | Auto-register via forge token | No manual key management for users |
|
||||||
|
| Sync strategy | Lazy/manual | User-triggered claims, manual stats sync |
|
||||||
|
| Offline mode | Offline-first | Queue failed writes, retry on reconnect |
|
||||||
|
| Approach | Thin HTTP client (net/http) | Matches existing patterns, no deps |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**File:** `internal/bugseti/hub.go` + `hub_test.go`
|
||||||
|
|
||||||
|
```
|
||||||
|
HubService
|
||||||
|
├── HTTP client (net/http, 10s timeout)
|
||||||
|
├── Auth: auto-register via forge token → cached ak_ token
|
||||||
|
├── Config: HubURL, HubToken, ClientID in ConfigService
|
||||||
|
├── Offline-first: queue failed writes, drain on next success
|
||||||
|
└── Lazy sync: user-triggered, no background goroutines
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependencies:** ConfigService only.
|
||||||
|
|
||||||
|
**Integration:**
|
||||||
|
- QueueService calls `hub.ClaimIssue()` when user picks an issue
|
||||||
|
- SubmitService calls `hub.UpdateStatus("completed")` after PR
|
||||||
|
- TrayService calls `hub.GetLeaderboard()` from UI
|
||||||
|
- main.go calls `hub.Register()` on startup
|
||||||
|
|
||||||
|
## Data Types
|
||||||
|
|
||||||
|
```go
|
||||||
|
type HubClient struct {
|
||||||
|
ClientID string // UUID, generated once, persisted in config
|
||||||
|
Name string // e.g. "Snider's MacBook"
|
||||||
|
Version string // bugseti.GetVersion()
|
||||||
|
OS string // runtime.GOOS
|
||||||
|
Arch string // runtime.GOARCH
|
||||||
|
}
|
||||||
|
|
||||||
|
type HubClaim struct {
|
||||||
|
IssueID string // "owner/repo#123"
|
||||||
|
Repo string
|
||||||
|
IssueNumber int
|
||||||
|
Title string
|
||||||
|
URL string
|
||||||
|
Status string // claimed|in_progress|completed|skipped
|
||||||
|
ClaimedAt time.Time
|
||||||
|
PRUrl string
|
||||||
|
PRNumber int
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeaderboardEntry struct {
|
||||||
|
Rank int
|
||||||
|
ClientName string
|
||||||
|
IssuesCompleted int
|
||||||
|
PRsSubmitted int
|
||||||
|
PRsMerged int
|
||||||
|
CurrentStreak int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalStats struct {
|
||||||
|
TotalParticipants int
|
||||||
|
ActiveParticipants int
|
||||||
|
TotalIssuesCompleted int
|
||||||
|
TotalPRsMerged int
|
||||||
|
ActiveClaims int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Mapping
|
||||||
|
|
||||||
|
| Method | HTTP | Endpoint | Trigger |
|
||||||
|
|--------|------|----------|---------|
|
||||||
|
| `Register()` | POST /register | App startup |
|
||||||
|
| `Heartbeat()` | POST /heartbeat | Manual / periodic if enabled |
|
||||||
|
| `ClaimIssue(issue)` | POST /issues/claim | User picks issue |
|
||||||
|
| `UpdateStatus(id, status)` | PATCH /issues/{id}/status | PR submitted, skip |
|
||||||
|
| `ReleaseClaim(id)` | DELETE /issues/{id}/claim | User abandons |
|
||||||
|
| `IsIssueClaimed(id)` | GET /issues/{id} | Before showing issue |
|
||||||
|
| `ListClaims(filters)` | GET /issues/claimed | UI active claims view |
|
||||||
|
| `SyncStats(stats)` | POST /stats/sync | Manual from UI |
|
||||||
|
| `GetLeaderboard(limit)` | GET /leaderboard | UI leaderboard view |
|
||||||
|
| `GetGlobalStats()` | GET /stats | UI stats dashboard |
|
||||||
|
|
||||||
|
## Auto-Register Flow
|
||||||
|
|
||||||
|
New endpoint on portal:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/bugseti/auth/forge
|
||||||
|
Body: { "forge_url": "https://forge.lthn.io", "forge_token": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Portal validates token against Forgejo API (`/api/v1/user`), creates an AgentApiKey with `bugseti.read` + `bugseti.write` scopes, returns `{ "api_key": "ak_..." }`.
|
||||||
|
|
||||||
|
HubService caches the `ak_` token in config.json. On 401, clears cached token and re-registers.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Error | Behaviour |
|
||||||
|
|-------|-----------|
|
||||||
|
| Network unreachable | Log, queue write ops, return cached reads |
|
||||||
|
| 401 Unauthorised | Clear token, re-register via forge |
|
||||||
|
| 409 Conflict (claim) | Return "already claimed" — not an error |
|
||||||
|
| 404 (claim not found) | Return nil |
|
||||||
|
| 429 Rate limited | Back off, queue the op |
|
||||||
|
| 5xx Server error | Log, queue write ops |
|
||||||
|
|
||||||
|
**Pending operations queue:**
|
||||||
|
- Failed writes stored in `[]PendingOp`, persisted to `$DataDir/hub_pending.json`
|
||||||
|
- Drained on next successful user-triggered call (no background goroutine)
|
||||||
|
- Each op has: method, path, body, created_at
|
||||||
|
|
||||||
|
## Config Changes
|
||||||
|
|
||||||
|
New fields in `Config` struct:
|
||||||
|
|
||||||
|
```go
|
||||||
|
HubURL string `json:"hubUrl,omitempty"` // portal API base URL
|
||||||
|
HubToken string `json:"hubToken,omitempty"` // cached ak_ token
|
||||||
|
ClientID string `json:"clientId,omitempty"` // UUID, generated once
|
||||||
|
ClientName string `json:"clientName,omitempty"` // display name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/bugseti/hub.go` | New — HubService |
|
||||||
|
| `internal/bugseti/hub_test.go` | New — httptest-based tests |
|
||||||
|
| `internal/bugseti/config.go` | Edit — add Hub* + ClientID fields |
|
||||||
|
| `cmd/bugseti/main.go` | Edit — create + register HubService |
|
||||||
|
| `cmd/bugseti/tray.go` | Edit — leaderboard/stats menu items |
|
||||||
|
| Laravel: auth controller | New — `/api/bugseti/auth/forge` |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `httptest.NewServer` mocks for all endpoints
|
||||||
|
- Test success, network error, 409 conflict, 401 re-auth flows
|
||||||
|
- Test pending ops queue: add when offline, drain on reconnect
|
||||||
|
- `_Good`, `_Bad`, `_Ugly` naming convention
|
||||||
1620
docs/plans/2026-02-13-bugseti-hub-service-plan.md
Normal file
1620
docs/plans/2026-02-13-bugseti-hub-service-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,6 +23,12 @@ type Config struct {
|
||||||
ForgeURL string `json:"forgeUrl,omitempty"`
|
ForgeURL string `json:"forgeUrl,omitempty"`
|
||||||
ForgeToken string `json:"forgeToken,omitempty"`
|
ForgeToken string `json:"forgeToken,omitempty"`
|
||||||
|
|
||||||
|
// Hub coordination (agentic portal)
|
||||||
|
HubURL string `json:"hubUrl,omitempty"`
|
||||||
|
HubToken string `json:"hubToken,omitempty"`
|
||||||
|
ClientID string `json:"clientId,omitempty"`
|
||||||
|
ClientName string `json:"clientName,omitempty"`
|
||||||
|
|
||||||
// Deprecated: use ForgeToken. Kept for migration.
|
// Deprecated: use ForgeToken. Kept for migration.
|
||||||
GitHubToken string `json:"githubToken,omitempty"`
|
GitHubToken string `json:"githubToken,omitempty"`
|
||||||
|
|
||||||
|
|
@ -546,6 +552,82 @@ func (c *ConfigService) GetForgeToken() string {
|
||||||
return c.config.ForgeToken
|
return c.config.ForgeToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetForgeURL sets the Forge URL.
|
||||||
|
func (c *ConfigService) SetForgeURL(url string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.config.ForgeURL = url
|
||||||
|
return c.saveUnsafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetForgeToken sets the Forge token.
|
||||||
|
func (c *ConfigService) SetForgeToken(token string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.config.ForgeToken = token
|
||||||
|
return c.saveUnsafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHubURL returns the configured Hub URL.
|
||||||
|
func (c *ConfigService) GetHubURL() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.config.HubURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHubURL sets the Hub URL.
|
||||||
|
func (c *ConfigService) SetHubURL(url string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.config.HubURL = url
|
||||||
|
return c.saveUnsafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHubToken returns the configured Hub token.
|
||||||
|
func (c *ConfigService) GetHubToken() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.config.HubToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHubToken sets the Hub token.
|
||||||
|
func (c *ConfigService) SetHubToken(token string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.config.HubToken = token
|
||||||
|
return c.saveUnsafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientID returns the configured client ID.
|
||||||
|
func (c *ConfigService) GetClientID() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.config.ClientID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClientID sets the client ID.
|
||||||
|
func (c *ConfigService) SetClientID(id string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.config.ClientID = id
|
||||||
|
return c.saveUnsafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientName returns the configured client name.
|
||||||
|
func (c *ConfigService) GetClientName() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.config.ClientName
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClientName sets the client name.
|
||||||
|
func (c *ConfigService) SetClientName(name string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.config.ClientName = name
|
||||||
|
return c.saveUnsafe()
|
||||||
|
}
|
||||||
|
|
||||||
// ShouldCheckForUpdates returns true if it's time to check for updates.
|
// ShouldCheckForUpdates returns true if it's time to check for updates.
|
||||||
func (c *ConfigService) ShouldCheckForUpdates() bool {
|
func (c *ConfigService) ShouldCheckForUpdates() bool {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
|
|
|
||||||
576
internal/bugseti/hub.go
Normal file
576
internal/bugseti/hub.go
Normal file
|
|
@ -0,0 +1,576 @@
|
||||||
|
// 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"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HubService coordinates with the agentic portal for issue assignment and leaderboard.
|
||||||
|
type HubService struct {
|
||||||
|
config *ConfigService
|
||||||
|
client *http.Client
|
||||||
|
connected bool
|
||||||
|
pending []PendingOp
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingOp represents an operation queued for retry when the hub is unreachable.
|
||||||
|
type PendingOp struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Body json.RawMessage `json:"body,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HubClaim represents a claimed issue from the hub.
|
||||||
|
type HubClaim struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
IssueURL string `json:"issueUrl"`
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
|
ClaimedAt time.Time `json:"claimedAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaderboardEntry represents a single entry on the leaderboard.
|
||||||
|
type LeaderboardEntry struct {
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
|
ClientName string `json:"clientName"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
PRsMerged int `json:"prsMerged"`
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlobalStats holds aggregate statistics from the hub.
|
||||||
|
type GlobalStats struct {
|
||||||
|
TotalClients int `json:"totalClients"`
|
||||||
|
TotalClaims int `json:"totalClaims"`
|
||||||
|
TotalPRsMerged int `json:"totalPrsMerged"`
|
||||||
|
ActiveClaims int `json:"activeClaims"`
|
||||||
|
IssuesAvailable int `json:"issuesAvailable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConflictError indicates a 409 response from the hub (e.g. issue already claimed).
|
||||||
|
type ConflictError struct {
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ConflictError) Error() string {
|
||||||
|
return fmt.Sprintf("conflict: status %d", e.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotFoundError indicates a 404 response from the hub.
|
||||||
|
type NotFoundError struct {
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NotFoundError) Error() string {
|
||||||
|
return fmt.Sprintf("not found: status %d", e.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHubService creates a new HubService with the given config.
|
||||||
|
// If the config has no ClientID, one is generated and persisted.
|
||||||
|
func NewHubService(config *ConfigService) *HubService {
|
||||||
|
h := &HubService{
|
||||||
|
config: config,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
pending: make([]PendingOp, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate client ID if not set.
|
||||||
|
if config.GetClientID() == "" {
|
||||||
|
id := generateClientID()
|
||||||
|
_ = config.SetClientID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.loadPendingOps()
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceName returns the service name for Wails.
|
||||||
|
func (h *HubService) ServiceName() string {
|
||||||
|
return "HubService"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientID returns the client ID from config.
|
||||||
|
func (h *HubService) GetClientID() string {
|
||||||
|
return h.config.GetClientID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected returns whether the hub was reachable on the last request.
|
||||||
|
func (h *HubService) IsConnected() bool {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return h.connected
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateClientID creates a random hex string (16 bytes = 32 hex chars).
|
||||||
|
func generateClientID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// Fallback: this should never happen with crypto/rand.
|
||||||
|
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRequest builds and executes an HTTP request against the hub API.
|
||||||
|
// It returns the raw *http.Response and any transport-level error.
|
||||||
|
func (h *HubService) doRequest(method, path string, body interface{}) (*http.Response, error) {
|
||||||
|
hubURL := h.config.GetHubURL()
|
||||||
|
if hubURL == "" {
|
||||||
|
return nil, fmt.Errorf("hub URL not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
fullURL := hubURL + "/api/bugseti" + path
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
token := h.config.GetHubToken()
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.connected = false
|
||||||
|
h.mu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.connected = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doJSON executes an HTTP request and decodes the JSON response into dest.
|
||||||
|
// It handles common error status codes with typed errors.
|
||||||
|
func (h *HubService) doJSON(method, path string, body, dest interface{}) error {
|
||||||
|
resp, err := h.doRequest(method, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == http.StatusUnauthorized:
|
||||||
|
return fmt.Errorf("unauthorised")
|
||||||
|
case resp.StatusCode == http.StatusConflict:
|
||||||
|
return &ConflictError{StatusCode: resp.StatusCode}
|
||||||
|
case resp.StatusCode == http.StatusNotFound:
|
||||||
|
return &NotFoundError{StatusCode: resp.StatusCode}
|
||||||
|
case resp.StatusCode >= 400:
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("hub error %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
if dest != nil {
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
|
||||||
|
return fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueOp marshals body to JSON and appends a PendingOp to the queue.
|
||||||
|
func (h *HubService) queueOp(method, path string, body interface{}) {
|
||||||
|
var raw json.RawMessage
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("BugSETI: queueOp marshal error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw = data
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.pending = append(h.pending, PendingOp{
|
||||||
|
Method: method,
|
||||||
|
Path: path,
|
||||||
|
Body: raw,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
h.savePendingOps()
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainPendingOps replays queued operations against the hub.
|
||||||
|
// 5xx/transport errors are kept for retry; 4xx responses are dropped (stale).
|
||||||
|
func (h *HubService) drainPendingOps() {
|
||||||
|
h.mu.Lock()
|
||||||
|
ops := h.pending
|
||||||
|
h.pending = make([]PendingOp, 0)
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if len(ops) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed []PendingOp
|
||||||
|
for _, op := range ops {
|
||||||
|
var body interface{}
|
||||||
|
if len(op.Body) > 0 {
|
||||||
|
body = json.RawMessage(op.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.doRequest(op.Method, op.Path, body)
|
||||||
|
if err != nil {
|
||||||
|
// Transport error — keep for retry.
|
||||||
|
failed = append(failed, op)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
// Server error — keep for retry.
|
||||||
|
failed = append(failed, op)
|
||||||
|
} // 4xx are dropped (stale).
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(failed) > 0 {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.pending = append(failed, h.pending...)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
h.savePendingOps()
|
||||||
|
}
|
||||||
|
|
||||||
|
// savePendingOps persists the pending operations queue to disk.
|
||||||
|
func (h *HubService) savePendingOps() {
|
||||||
|
dataDir := h.config.GetDataDir()
|
||||||
|
if dataDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
data, err := json.Marshal(h.pending)
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("BugSETI: savePendingOps marshal error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dataDir, "hub_pending.json")
|
||||||
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
log.Printf("BugSETI: savePendingOps write error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPendingOps loads the pending operations queue from disk.
|
||||||
|
// Errors are silently ignored (the file may not exist yet).
|
||||||
|
func (h *HubService) loadPendingOps() {
|
||||||
|
dataDir := h.config.GetDataDir()
|
||||||
|
if dataDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dataDir, "hub_pending.json")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var ops []PendingOp
|
||||||
|
if err := json.Unmarshal(data, &ops); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.pending = ops
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingCount returns the number of queued pending operations.
|
||||||
|
func (h *HubService) PendingCount() int {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return len(h.pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Task 4: Auto-Register via Forge Token ----
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Task 5: Write Operations ----
|
||||||
|
|
||||||
|
// Register registers this client with the hub.
|
||||||
|
func (h *HubService) Register() error {
|
||||||
|
h.drainPendingOps()
|
||||||
|
|
||||||
|
name := h.config.GetClientName()
|
||||||
|
clientID := h.config.GetClientID()
|
||||||
|
if name == "" {
|
||||||
|
if len(clientID) >= 8 {
|
||||||
|
name = "BugSETI-" + clientID[:8]
|
||||||
|
} else {
|
||||||
|
name = "BugSETI-" + clientID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]string{
|
||||||
|
"client_id": clientID,
|
||||||
|
"name": name,
|
||||||
|
"version": GetVersion(),
|
||||||
|
"os": runtime.GOOS,
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.doJSON("POST", "/register", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat sends a heartbeat to the hub.
|
||||||
|
func (h *HubService) Heartbeat() error {
|
||||||
|
body := map[string]string{
|
||||||
|
"client_id": h.config.GetClientID(),
|
||||||
|
}
|
||||||
|
return h.doJSON("POST", "/heartbeat", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimIssue claims an issue on the hub, returning the claim details.
|
||||||
|
// Returns a ConflictError if the issue is already claimed by another client.
|
||||||
|
func (h *HubService) ClaimIssue(issue *Issue) (*HubClaim, error) {
|
||||||
|
h.drainPendingOps()
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"client_id": h.config.GetClientID(),
|
||||||
|
"issue_id": issue.ID,
|
||||||
|
"repo": issue.Repo,
|
||||||
|
"issue_number": issue.Number,
|
||||||
|
"title": issue.Title,
|
||||||
|
"url": issue.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
var claim HubClaim
|
||||||
|
if err := h.doJSON("POST", "/issues/claim", body, &claim); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &claim, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus updates the status of a claimed issue on the hub.
|
||||||
|
func (h *HubService) UpdateStatus(issueID, status, prURL string, prNumber int) error {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"client_id": h.config.GetClientID(),
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if prURL != "" {
|
||||||
|
body["pr_url"] = prURL
|
||||||
|
}
|
||||||
|
if prNumber > 0 {
|
||||||
|
body["pr_number"] = prNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/issues/" + url.PathEscape(issueID) + "/status"
|
||||||
|
return h.doJSON("PATCH", path, body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseClaim releases a previously claimed issue back to the pool.
|
||||||
|
func (h *HubService) ReleaseClaim(issueID string) error {
|
||||||
|
body := map[string]string{
|
||||||
|
"client_id": h.config.GetClientID(),
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/issues/" + url.PathEscape(issueID) + "/claim"
|
||||||
|
return h.doJSON("DELETE", path, body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStats uploads local statistics to the hub.
|
||||||
|
func (h *HubService) SyncStats(stats *Stats) error {
|
||||||
|
// Build repos_contributed as a flat string slice from the map keys.
|
||||||
|
repos := make([]string, 0, len(stats.ReposContributed))
|
||||||
|
for k := range stats.ReposContributed {
|
||||||
|
repos = append(repos, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"client_id": h.config.GetClientID(),
|
||||||
|
"stats": map[string]interface{}{
|
||||||
|
"issues_attempted": stats.IssuesAttempted,
|
||||||
|
"issues_completed": stats.IssuesCompleted,
|
||||||
|
"issues_skipped": stats.IssuesSkipped,
|
||||||
|
"prs_submitted": stats.PRsSubmitted,
|
||||||
|
"prs_merged": stats.PRsMerged,
|
||||||
|
"prs_rejected": stats.PRsRejected,
|
||||||
|
"current_streak": stats.CurrentStreak,
|
||||||
|
"longest_streak": stats.LongestStreak,
|
||||||
|
"total_time_minutes": int(stats.TotalTimeSpent.Minutes()),
|
||||||
|
"repos_contributed": repos,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.doJSON("POST", "/stats/sync", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Task 6: Read Operations ----
|
||||||
|
|
||||||
|
// IsIssueClaimed checks whether an issue is currently claimed on the hub.
|
||||||
|
// Returns the claim if it exists, or (nil, nil) if the issue is not claimed (404).
|
||||||
|
func (h *HubService) IsIssueClaimed(issueID string) (*HubClaim, error) {
|
||||||
|
path := "/issues/" + url.PathEscape(issueID)
|
||||||
|
|
||||||
|
var claim HubClaim
|
||||||
|
if err := h.doJSON("GET", path, nil, &claim); err != nil {
|
||||||
|
if _, ok := err.(*NotFoundError); ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &claim, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListClaims returns claimed issues, optionally filtered by status and/or repo.
|
||||||
|
func (h *HubService) ListClaims(status, repo string) ([]*HubClaim, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
if status != "" {
|
||||||
|
params.Set("status", status)
|
||||||
|
}
|
||||||
|
if repo != "" {
|
||||||
|
params.Set("repo", repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/issues/claimed"
|
||||||
|
if encoded := params.Encode(); encoded != "" {
|
||||||
|
path += "?" + encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims []*HubClaim
|
||||||
|
if err := h.doJSON("GET", path, nil, &claims); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// leaderboardResponse wraps the hub leaderboard JSON envelope.
|
||||||
|
type leaderboardResponse struct {
|
||||||
|
Entries []LeaderboardEntry `json:"entries"`
|
||||||
|
TotalParticipants int `json:"totalParticipants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLeaderboard fetches the top N leaderboard entries from the hub.
|
||||||
|
func (h *HubService) GetLeaderboard(limit int) ([]LeaderboardEntry, int, error) {
|
||||||
|
path := fmt.Sprintf("/leaderboard?limit=%d", limit)
|
||||||
|
|
||||||
|
var resp leaderboardResponse
|
||||||
|
if err := h.doJSON("GET", path, nil, &resp); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return resp.Entries, resp.TotalParticipants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGlobalStats fetches aggregate statistics from the hub.
|
||||||
|
func (h *HubService) GetGlobalStats() (*GlobalStats, error) {
|
||||||
|
var stats GlobalStats
|
||||||
|
if err := h.doJSON("GET", "/stats", nil, &stats); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &stats, nil
|
||||||
|
}
|
||||||
558
internal/bugseti/hub_test.go
Normal file
558
internal/bugseti/hub_test.go
Normal file
|
|
@ -0,0 +1,558 @@
|
||||||
|
package bugseti
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testHubService(t *testing.T, serverURL string) *HubService {
|
||||||
|
t.Helper()
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
if serverURL != "" {
|
||||||
|
cfg.config.HubURL = serverURL
|
||||||
|
}
|
||||||
|
return NewHubService(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NewHubService ----
|
||||||
|
|
||||||
|
func TestNewHubService_Good(t *testing.T) {
|
||||||
|
h := testHubService(t, "")
|
||||||
|
require.NotNil(t, h)
|
||||||
|
assert.NotNil(t, h.config)
|
||||||
|
assert.NotNil(t, h.client)
|
||||||
|
assert.False(t, h.IsConnected())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubServiceName_Good(t *testing.T) {
|
||||||
|
h := testHubService(t, "")
|
||||||
|
assert.Equal(t, "HubService", h.ServiceName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHubService_Good_GeneratesClientID(t *testing.T) {
|
||||||
|
h := testHubService(t, "")
|
||||||
|
id := h.GetClientID()
|
||||||
|
assert.NotEmpty(t, id)
|
||||||
|
// 16 bytes = 32 hex characters
|
||||||
|
assert.Len(t, id, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHubService_Good_ReusesClientID(t *testing.T) {
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.ClientID = "existing-client-id"
|
||||||
|
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
assert.Equal(t, "existing-client-id", h.GetClientID())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- doRequest ----
|
||||||
|
|
||||||
|
func TestDoRequest_Good(t *testing.T) {
|
||||||
|
var gotAuth string
|
||||||
|
var gotContentType string
|
||||||
|
var gotAccept string
|
||||||
|
var gotBody map[string]string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
gotContentType = r.Header.Get("Content-Type")
|
||||||
|
gotAccept = r.Header.Get("Accept")
|
||||||
|
|
||||||
|
if r.Body != nil {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "test-token-123"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
body := map[string]string{"key": "value"}
|
||||||
|
resp, err := h.doRequest("POST", "/test", body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Equal(t, "Bearer test-token-123", gotAuth)
|
||||||
|
assert.Equal(t, "application/json", gotContentType)
|
||||||
|
assert.Equal(t, "application/json", gotAccept)
|
||||||
|
assert.Equal(t, "value", gotBody["key"])
|
||||||
|
assert.True(t, h.IsConnected())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_Bad_NoHubURL(t *testing.T) {
|
||||||
|
h := testHubService(t, "")
|
||||||
|
|
||||||
|
resp, err := h.doRequest("GET", "/test", nil)
|
||||||
|
assert.Nil(t, resp)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "hub URL not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoRequest_Bad_NetworkError(t *testing.T) {
|
||||||
|
// Point to a port where nothing is listening.
|
||||||
|
h := testHubService(t, "http://127.0.0.1:1")
|
||||||
|
|
||||||
|
resp, err := h.doRequest("GET", "/test", nil)
|
||||||
|
assert.Nil(t, resp)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.False(t, h.IsConnected())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Task 4: 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Task 5: Write Operations ----
|
||||||
|
|
||||||
|
func TestRegister_Good(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotMethod string
|
||||||
|
var gotBody map[string]string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotMethod = r.Method
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
cfg.config.ClientName = "MyBugSETI"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
err := h.Register()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/api/bugseti/register", gotPath)
|
||||||
|
assert.Equal(t, "POST", gotMethod)
|
||||||
|
assert.Equal(t, "MyBugSETI", gotBody["name"])
|
||||||
|
assert.NotEmpty(t, gotBody["client_id"])
|
||||||
|
assert.NotEmpty(t, gotBody["version"])
|
||||||
|
assert.NotEmpty(t, gotBody["os"])
|
||||||
|
assert.NotEmpty(t, gotBody["arch"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeat_Good(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotMethod string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotMethod = r.Method
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
err := h.Heartbeat()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/api/bugseti/heartbeat", gotPath)
|
||||||
|
assert.Equal(t, "POST", gotMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimIssue_Good(t *testing.T) {
|
||||||
|
now := time.Now().Truncate(time.Second)
|
||||||
|
expires := now.Add(30 * time.Minute)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/bugseti/issues/claim", r.URL.Path)
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
assert.Equal(t, "issue-42", body["issue_id"])
|
||||||
|
assert.Equal(t, "org/repo", body["repo"])
|
||||||
|
assert.Equal(t, float64(42), body["issue_number"])
|
||||||
|
assert.Equal(t, "Fix the bug", body["title"])
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := HubClaim{
|
||||||
|
ID: "claim-1",
|
||||||
|
IssueURL: "https://github.com/org/repo/issues/42",
|
||||||
|
ClientID: "test",
|
||||||
|
ClaimedAt: now,
|
||||||
|
ExpiresAt: expires,
|
||||||
|
Status: "claimed",
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
issue := &Issue{
|
||||||
|
ID: "issue-42",
|
||||||
|
Number: 42,
|
||||||
|
Repo: "org/repo",
|
||||||
|
Title: "Fix the bug",
|
||||||
|
URL: "https://github.com/org/repo/issues/42",
|
||||||
|
}
|
||||||
|
|
||||||
|
claim, err := h.ClaimIssue(issue)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, claim)
|
||||||
|
assert.Equal(t, "claim-1", claim.ID)
|
||||||
|
assert.Equal(t, "claimed", claim.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimIssue_Bad_Conflict(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
issue := &Issue{ID: "issue-99", Number: 99, Repo: "org/repo", Title: "Already claimed"}
|
||||||
|
|
||||||
|
claim, err := h.ClaimIssue(issue)
|
||||||
|
assert.Nil(t, claim)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
var conflictErr *ConflictError
|
||||||
|
assert.ErrorAs(t, err, &conflictErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateStatus_Good(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotMethod string
|
||||||
|
var gotBody map[string]interface{}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotMethod = r.Method
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
err := h.UpdateStatus("issue-42", "completed", "https://github.com/org/repo/pull/10", 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "PATCH", gotMethod)
|
||||||
|
assert.Equal(t, "/api/bugseti/issues/issue-42/status", gotPath)
|
||||||
|
assert.Equal(t, "completed", gotBody["status"])
|
||||||
|
assert.Equal(t, "https://github.com/org/repo/pull/10", gotBody["pr_url"])
|
||||||
|
assert.Equal(t, float64(10), gotBody["pr_number"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncStats_Good(t *testing.T) {
|
||||||
|
var gotBody map[string]interface{}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/bugseti/stats/sync", r.URL.Path)
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
stats := &Stats{
|
||||||
|
IssuesAttempted: 10,
|
||||||
|
IssuesCompleted: 7,
|
||||||
|
IssuesSkipped: 3,
|
||||||
|
PRsSubmitted: 6,
|
||||||
|
PRsMerged: 5,
|
||||||
|
PRsRejected: 1,
|
||||||
|
CurrentStreak: 3,
|
||||||
|
LongestStreak: 5,
|
||||||
|
TotalTimeSpent: 90 * time.Minute,
|
||||||
|
ReposContributed: map[string]*RepoStats{
|
||||||
|
"org/repo-a": {Name: "org/repo-a"},
|
||||||
|
"org/repo-b": {Name: "org/repo-b"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.SyncStats(stats)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, gotBody["client_id"])
|
||||||
|
statsMap, ok := gotBody["stats"].(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, float64(10), statsMap["issues_attempted"])
|
||||||
|
assert.Equal(t, float64(7), statsMap["issues_completed"])
|
||||||
|
assert.Equal(t, float64(3), statsMap["issues_skipped"])
|
||||||
|
assert.Equal(t, float64(6), statsMap["prs_submitted"])
|
||||||
|
assert.Equal(t, float64(5), statsMap["prs_merged"])
|
||||||
|
assert.Equal(t, float64(1), statsMap["prs_rejected"])
|
||||||
|
assert.Equal(t, float64(3), statsMap["current_streak"])
|
||||||
|
assert.Equal(t, float64(5), statsMap["longest_streak"])
|
||||||
|
assert.Equal(t, float64(90), statsMap["total_time_minutes"])
|
||||||
|
|
||||||
|
reposRaw, ok := statsMap["repos_contributed"].([]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Len(t, reposRaw, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Task 6: Read Operations ----
|
||||||
|
|
||||||
|
func TestIsIssueClaimed_Good_Claimed(t *testing.T) {
|
||||||
|
now := time.Now().Truncate(time.Second)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/bugseti/issues/issue-42", r.URL.Path)
|
||||||
|
assert.Equal(t, "GET", r.Method)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
claim := HubClaim{
|
||||||
|
ID: "claim-1",
|
||||||
|
IssueURL: "https://github.com/org/repo/issues/42",
|
||||||
|
ClientID: "client-abc",
|
||||||
|
ClaimedAt: now,
|
||||||
|
Status: "claimed",
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(claim)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
claim, err := h.IsIssueClaimed("issue-42")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, claim)
|
||||||
|
assert.Equal(t, "claim-1", claim.ID)
|
||||||
|
assert.Equal(t, "claimed", claim.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsIssueClaimed_Good_NotClaimed(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
claim, err := h.IsIssueClaimed("issue-999")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, claim)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLeaderboard_Good(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/bugseti/leaderboard", r.URL.Path)
|
||||||
|
assert.Equal(t, "GET", r.Method)
|
||||||
|
assert.Equal(t, "10", r.URL.Query().Get("limit"))
|
||||||
|
|
||||||
|
resp := leaderboardResponse{
|
||||||
|
Entries: []LeaderboardEntry{
|
||||||
|
{ClientID: "a", ClientName: "Alice", Score: 100, PRsMerged: 10, Rank: 1},
|
||||||
|
{ClientID: "b", ClientName: "Bob", Score: 80, PRsMerged: 8, Rank: 2},
|
||||||
|
},
|
||||||
|
TotalParticipants: 42,
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
entries, total, err := h.GetLeaderboard(10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 42, total)
|
||||||
|
require.Len(t, entries, 2)
|
||||||
|
assert.Equal(t, "Alice", entries[0].ClientName)
|
||||||
|
assert.Equal(t, 1, entries[0].Rank)
|
||||||
|
assert.Equal(t, "Bob", entries[1].ClientName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetGlobalStats_Good(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/bugseti/stats", r.URL.Path)
|
||||||
|
assert.Equal(t, "GET", r.Method)
|
||||||
|
|
||||||
|
stats := GlobalStats{
|
||||||
|
TotalClients: 100,
|
||||||
|
TotalClaims: 500,
|
||||||
|
TotalPRsMerged: 300,
|
||||||
|
ActiveClaims: 25,
|
||||||
|
IssuesAvailable: 150,
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(stats)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
stats, err := h.GetGlobalStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, stats)
|
||||||
|
assert.Equal(t, 100, stats.TotalClients)
|
||||||
|
assert.Equal(t, 500, stats.TotalClaims)
|
||||||
|
assert.Equal(t, 300, stats.TotalPRsMerged)
|
||||||
|
assert.Equal(t, 25, stats.ActiveClaims)
|
||||||
|
assert.Equal(t, 150, stats.IssuesAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Task 7: Pending Operations Queue ----
|
||||||
|
|
||||||
|
func TestPendingOps_Good_QueueAndDrain(t *testing.T) {
|
||||||
|
var callCount int32
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
// Manually queue a pending op (simulates a previous failed request).
|
||||||
|
h.queueOp("POST", "/heartbeat", map[string]string{"client_id": "test"})
|
||||||
|
assert.Equal(t, 1, h.PendingCount())
|
||||||
|
|
||||||
|
// Register() calls drainPendingOps() first, then sends its own request.
|
||||||
|
err := h.Register()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// At least 2 calls: 1 from drain (the queued heartbeat) + 1 from Register itself.
|
||||||
|
assert.GreaterOrEqual(t, callCount, int32(2))
|
||||||
|
assert.Equal(t, 0, h.PendingCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingOps_Good_PersistAndLoad(t *testing.T) {
|
||||||
|
cfg1 := testConfigService(t, nil, nil)
|
||||||
|
cfg1.config.HubURL = "https://hub.example.com"
|
||||||
|
cfg1.config.HubToken = "tok"
|
||||||
|
h1 := NewHubService(cfg1)
|
||||||
|
|
||||||
|
// Queue an op — this also calls savePendingOps.
|
||||||
|
h1.queueOp("POST", "/heartbeat", map[string]string{"client_id": "test"})
|
||||||
|
assert.Equal(t, 1, h1.PendingCount())
|
||||||
|
|
||||||
|
// Create a second HubService with the same data dir.
|
||||||
|
// NewHubService calls loadPendingOps() in its constructor.
|
||||||
|
cfg2 := testConfigService(t, nil, nil)
|
||||||
|
cfg2.config.DataDir = cfg1.config.DataDir // Share the same data dir.
|
||||||
|
cfg2.config.HubURL = "https://hub.example.com"
|
||||||
|
cfg2.config.HubToken = "tok"
|
||||||
|
h2 := NewHubService(cfg2)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, h2.PendingCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingCount_Good(t *testing.T) {
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = "https://hub.example.com"
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, h.PendingCount())
|
||||||
|
|
||||||
|
h.queueOp("POST", "/test1", nil)
|
||||||
|
assert.Equal(t, 1, h.PendingCount())
|
||||||
|
|
||||||
|
h.queueOp("POST", "/test2", map[string]string{"key": "val"})
|
||||||
|
assert.Equal(t, 2, h.PendingCount())
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue