feat(bugseti): add hub write operations
Add Register, Heartbeat, ClaimIssue, UpdateStatus, ReleaseClaim, and SyncStats methods for hub coordination. ClaimIssue returns ConflictError on 409 and calls drainPendingOps before mutating. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
21d5f5f6df
commit
a6456e2c6d
2 changed files with 314 additions and 0 deletions
|
|
@ -10,6 +10,8 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -287,3 +289,111 @@ func (h *HubService) AutoRegister() error {
|
||||||
log.Printf("BugSETI: auto-registered with hub, token cached")
|
log.Printf("BugSETI: auto-registered with hub, token cached")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register registers this client with the hub.
|
||||||
|
func (h *HubService) Register() error {
|
||||||
|
h.drainPendingOps()
|
||||||
|
|
||||||
|
name := h.config.GetClientName()
|
||||||
|
clientID := h.config.GetClientID()
|
||||||
|
if name == "" {
|
||||||
|
if len(clientID) >= 8 {
|
||||||
|
name = "BugSETI-" + clientID[:8]
|
||||||
|
} else {
|
||||||
|
name = "BugSETI-" + clientID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]string{
|
||||||
|
"client_id": clientID,
|
||||||
|
"name": name,
|
||||||
|
"version": GetVersion(),
|
||||||
|
"os": runtime.GOOS,
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.doJSON("POST", "/register", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat sends a heartbeat to the hub.
|
||||||
|
func (h *HubService) Heartbeat() error {
|
||||||
|
body := map[string]string{
|
||||||
|
"client_id": h.config.GetClientID(),
|
||||||
|
}
|
||||||
|
return h.doJSON("POST", "/heartbeat", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimIssue claims an issue on the hub, returning the claim details.
|
||||||
|
// Returns a ConflictError if the issue is already claimed by another client.
|
||||||
|
func (h *HubService) ClaimIssue(issue *Issue) (*HubClaim, error) {
|
||||||
|
h.drainPendingOps()
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"client_id": h.config.GetClientID(),
|
||||||
|
"issue_id": issue.ID,
|
||||||
|
"repo": issue.Repo,
|
||||||
|
"issue_number": issue.Number,
|
||||||
|
"title": issue.Title,
|
||||||
|
"url": issue.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
var claim HubClaim
|
||||||
|
if err := h.doJSON("POST", "/issues/claim", body, &claim); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &claim, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus updates the status of a claimed issue on the hub.
|
||||||
|
func (h *HubService) UpdateStatus(issueID, status, prURL string, prNumber int) error {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"client_id": h.config.GetClientID(),
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if prURL != "" {
|
||||||
|
body["pr_url"] = prURL
|
||||||
|
}
|
||||||
|
if prNumber > 0 {
|
||||||
|
body["pr_number"] = prNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/issues/" + url.PathEscape(issueID) + "/status"
|
||||||
|
return h.doJSON("PATCH", path, body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseClaim releases a previously claimed issue back to the pool.
|
||||||
|
func (h *HubService) ReleaseClaim(issueID string) error {
|
||||||
|
body := map[string]string{
|
||||||
|
"client_id": h.config.GetClientID(),
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/issues/" + url.PathEscape(issueID) + "/claim"
|
||||||
|
return h.doJSON("DELETE", path, body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStats uploads local statistics to the hub.
|
||||||
|
func (h *HubService) SyncStats(stats *Stats) error {
|
||||||
|
// Build repos_contributed as a flat string slice from the map keys.
|
||||||
|
repos := make([]string, 0, len(stats.ReposContributed))
|
||||||
|
for k := range stats.ReposContributed {
|
||||||
|
repos = append(repos, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"client_id": h.config.GetClientID(),
|
||||||
|
"stats": map[string]interface{}{
|
||||||
|
"issues_attempted": stats.IssuesAttempted,
|
||||||
|
"issues_completed": stats.IssuesCompleted,
|
||||||
|
"issues_skipped": stats.IssuesSkipped,
|
||||||
|
"prs_submitted": stats.PRsSubmitted,
|
||||||
|
"prs_merged": stats.PRsMerged,
|
||||||
|
"prs_rejected": stats.PRsRejected,
|
||||||
|
"current_streak": stats.CurrentStreak,
|
||||||
|
"longest_streak": stats.LongestStreak,
|
||||||
|
"total_time_minutes": int(stats.TotalTimeSpent.Minutes()),
|
||||||
|
"repos_contributed": repos,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.doJSON("POST", "/stats/sync", body, nil)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
@ -174,3 +175,206 @@ func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) {
|
||||||
// Token should remain unchanged.
|
// Token should remain unchanged.
|
||||||
assert.Equal(t, "existing-token", h.config.GetHubToken())
|
assert.Equal(t, "existing-token", h.config.GetHubToken())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Write Operations ----
|
||||||
|
|
||||||
|
func TestRegister_Good(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotMethod string
|
||||||
|
var gotBody map[string]string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotMethod = r.Method
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
cfg.config.ClientName = "MyBugSETI"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
err := h.Register()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/api/bugseti/register", gotPath)
|
||||||
|
assert.Equal(t, "POST", gotMethod)
|
||||||
|
assert.Equal(t, "MyBugSETI", gotBody["name"])
|
||||||
|
assert.NotEmpty(t, gotBody["client_id"])
|
||||||
|
assert.NotEmpty(t, gotBody["version"])
|
||||||
|
assert.NotEmpty(t, gotBody["os"])
|
||||||
|
assert.NotEmpty(t, gotBody["arch"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeat_Good(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotMethod string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotMethod = r.Method
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
err := h.Heartbeat()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/api/bugseti/heartbeat", gotPath)
|
||||||
|
assert.Equal(t, "POST", gotMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimIssue_Good(t *testing.T) {
|
||||||
|
now := time.Now().Truncate(time.Second)
|
||||||
|
expires := now.Add(30 * time.Minute)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/bugseti/issues/claim", r.URL.Path)
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
assert.Equal(t, "issue-42", body["issue_id"])
|
||||||
|
assert.Equal(t, "org/repo", body["repo"])
|
||||||
|
assert.Equal(t, float64(42), body["issue_number"])
|
||||||
|
assert.Equal(t, "Fix the bug", body["title"])
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := HubClaim{
|
||||||
|
ID: "claim-1",
|
||||||
|
IssueURL: "https://github.com/org/repo/issues/42",
|
||||||
|
ClientID: "test",
|
||||||
|
ClaimedAt: now,
|
||||||
|
ExpiresAt: expires,
|
||||||
|
Status: "claimed",
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
issue := &Issue{
|
||||||
|
ID: "issue-42",
|
||||||
|
Number: 42,
|
||||||
|
Repo: "org/repo",
|
||||||
|
Title: "Fix the bug",
|
||||||
|
URL: "https://github.com/org/repo/issues/42",
|
||||||
|
}
|
||||||
|
|
||||||
|
claim, err := h.ClaimIssue(issue)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, claim)
|
||||||
|
assert.Equal(t, "claim-1", claim.ID)
|
||||||
|
assert.Equal(t, "claimed", claim.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimIssue_Bad_Conflict(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
issue := &Issue{ID: "issue-99", Number: 99, Repo: "org/repo", Title: "Already claimed"}
|
||||||
|
|
||||||
|
claim, err := h.ClaimIssue(issue)
|
||||||
|
assert.Nil(t, claim)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
var conflictErr *ConflictError
|
||||||
|
assert.ErrorAs(t, err, &conflictErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateStatus_Good(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotMethod string
|
||||||
|
var gotBody map[string]interface{}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotMethod = r.Method
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
err := h.UpdateStatus("issue-42", "completed", "https://github.com/org/repo/pull/10", 10)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "PATCH", gotMethod)
|
||||||
|
assert.Equal(t, "/api/bugseti/issues/issue-42/status", gotPath)
|
||||||
|
assert.Equal(t, "completed", gotBody["status"])
|
||||||
|
assert.Equal(t, "https://github.com/org/repo/pull/10", gotBody["pr_url"])
|
||||||
|
assert.Equal(t, float64(10), gotBody["pr_number"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncStats_Good(t *testing.T) {
|
||||||
|
var gotBody map[string]interface{}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/api/bugseti/stats/sync", r.URL.Path)
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := testConfigService(t, nil, nil)
|
||||||
|
cfg.config.HubURL = srv.URL
|
||||||
|
cfg.config.HubToken = "tok"
|
||||||
|
h := NewHubService(cfg)
|
||||||
|
|
||||||
|
stats := &Stats{
|
||||||
|
IssuesAttempted: 10,
|
||||||
|
IssuesCompleted: 7,
|
||||||
|
IssuesSkipped: 3,
|
||||||
|
PRsSubmitted: 6,
|
||||||
|
PRsMerged: 5,
|
||||||
|
PRsRejected: 1,
|
||||||
|
CurrentStreak: 3,
|
||||||
|
LongestStreak: 5,
|
||||||
|
TotalTimeSpent: 90 * time.Minute,
|
||||||
|
ReposContributed: map[string]*RepoStats{
|
||||||
|
"org/repo-a": {Name: "org/repo-a"},
|
||||||
|
"org/repo-b": {Name: "org/repo-b"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.SyncStats(stats)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, gotBody["client_id"])
|
||||||
|
statsMap, ok := gotBody["stats"].(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, float64(10), statsMap["issues_attempted"])
|
||||||
|
assert.Equal(t, float64(7), statsMap["issues_completed"])
|
||||||
|
assert.Equal(t, float64(3), statsMap["issues_skipped"])
|
||||||
|
assert.Equal(t, float64(6), statsMap["prs_submitted"])
|
||||||
|
assert.Equal(t, float64(5), statsMap["prs_merged"])
|
||||||
|
assert.Equal(t, float64(1), statsMap["prs_rejected"])
|
||||||
|
assert.Equal(t, float64(3), statsMap["current_streak"])
|
||||||
|
assert.Equal(t, float64(5), statsMap["longest_streak"])
|
||||||
|
assert.Equal(t, float64(90), statsMap["total_time_minutes"])
|
||||||
|
|
||||||
|
reposRaw, ok := statsMap["repos_contributed"].([]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Len(t, reposRaw, 2)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue