feat: implement Gitea aggregation layer with concurrent fetching
Add core aggregation layer wrapping the Gitea API for fan-out concurrent fetching across repos and organizations with caching. - internal/gitea/client.go: Gitea API client with aggregation - ListOrgs/ListOrgRepos/ListOrgsAndRepos for org enumeration - ListAllIssues: concurrent fetch across repos via goroutines with semaphore (5) - ListAllPullRequests: same pattern for PRs - GetTriageQueue: unassigned issues + open PRs, sorted by priority - CreateIssue, ApplyLabel, SubmitReview: write operations with cache invalidation - In-memory cache with 30s TTL using sync.RWMutex - internal/gitea/client_test.go: unit tests for caching, priority scoring, API calls with httptest server, and triage queue sorting Closes leeworks-agents/gitea-mobile#3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
c := NewClient("https://gitea.example.com")
|
||||
if c.baseURL != "https://gitea.example.com" {
|
||||
t.Errorf("baseURL = %q, want %q", c.baseURL, "https://gitea.example.com")
|
||||
}
|
||||
if c.maxConcurrent != 5 {
|
||||
t.Errorf("maxConcurrent = %d, want 5", c.maxConcurrent)
|
||||
}
|
||||
if c.cacheTTL != 30*time.Second {
|
||||
t.Errorf("cacheTTL = %v, want 30s", c.cacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_TrailingSlash(t *testing.T) {
|
||||
c := NewClient("https://gitea.example.com/")
|
||||
if c.baseURL != "https://gitea.example.com" {
|
||||
t.Errorf("baseURL = %q, want trailing slash removed", c.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
c := NewClient("https://gitea.example.com")
|
||||
|
||||
// Cache miss.
|
||||
_, ok := c.getFromCache("key1")
|
||||
if ok {
|
||||
t.Error("expected cache miss")
|
||||
}
|
||||
|
||||
// Cache set and hit.
|
||||
c.setCache("key1", "value1")
|
||||
val, ok := c.getFromCache("key1")
|
||||
if !ok {
|
||||
t.Fatal("expected cache hit")
|
||||
}
|
||||
if val.(string) != "value1" {
|
||||
t.Errorf("got %q, want %q", val, "value1")
|
||||
}
|
||||
|
||||
// Invalidate.
|
||||
c.invalidateCache("key")
|
||||
_, ok = c.getFromCache("key1")
|
||||
if ok {
|
||||
t.Error("expected cache miss after invalidation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheExpiry(t *testing.T) {
|
||||
c := NewClient("https://gitea.example.com")
|
||||
c.cacheTTL = 1 * time.Millisecond
|
||||
|
||||
c.setCache("key1", "value1")
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
_, ok := c.getFromCache("key1")
|
||||
if ok {
|
||||
t.Error("expected cache miss after TTL expiry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateAll(t *testing.T) {
|
||||
c := NewClient("https://gitea.example.com")
|
||||
c.setCache("key1", "value1")
|
||||
c.setCache("key2", "value2")
|
||||
|
||||
c.InvalidateAll()
|
||||
|
||||
_, ok1 := c.getFromCache("key1")
|
||||
_, ok2 := c.getFromCache("key2")
|
||||
if ok1 || ok2 {
|
||||
t.Error("expected all cache entries to be invalidated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
labels []string
|
||||
want int
|
||||
}{
|
||||
{[]string{"P1", "bug"}, 1},
|
||||
{[]string{"P2"}, 2},
|
||||
{[]string{"P3", "enhancement"}, 3},
|
||||
{[]string{"bug", "enhancement"}, 4},
|
||||
{nil, 4},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := priorityScore(tt.labels)
|
||||
if got != tt.want {
|
||||
t.Errorf("priorityScore(%v) = %d, want %d", tt.labels, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOrgs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/user/orgs" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "token test-token" {
|
||||
t.Error("missing or wrong Authorization header")
|
||||
}
|
||||
|
||||
orgs := []Org{
|
||||
{Name: "org1", FullName: "Organization 1"},
|
||||
{Name: "org2", FullName: "Organization 2"},
|
||||
}
|
||||
json.NewEncoder(w).Encode(orgs)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL)
|
||||
orgs, err := c.ListOrgs(context.Background(), "test-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(orgs) != 2 {
|
||||
t.Fatalf("got %d orgs, want 2", len(orgs))
|
||||
}
|
||||
if orgs[0].Name != "org1" {
|
||||
t.Errorf("orgs[0].Name = %q, want %q", orgs[0].Name, "org1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOrgs_Cached(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL)
|
||||
|
||||
// First call should hit the server.
|
||||
_, err := c.ListOrgs(context.Background(), "test-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Second call should use cache.
|
||||
_, err = c.ListOrgs(context.Background(), "test-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if callCount != 1 {
|
||||
t.Errorf("server called %d times, want 1 (cached)", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOrgRepos(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
repos := []Repo{
|
||||
{ID: 1, Name: "repo1", FullName: "org1/repo1"},
|
||||
{ID: 2, Name: "repo2", FullName: "org1/repo2"},
|
||||
}
|
||||
json.NewEncoder(w).Encode(repos)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(server.URL)
|
||||
repos, err := c.ListOrgRepos(context.Background(), "test-token", "org1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(repos) != 2 {
|
||||
t.Fatalf("got %d repos, want 2", len(repos))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTriageQueue_Sorting(t *testing.T) {
|
||||
queue := []TriageItem{
|
||||
{Title: "low", Labels: []string{"P3"}, UpdatedAt: time.Now()},
|
||||
{Title: "high", Labels: []string{"P1"}, UpdatedAt: time.Now()},
|
||||
{Title: "medium", Labels: []string{"P2"}, UpdatedAt: time.Now()},
|
||||
{Title: "none", Labels: nil, UpdatedAt: time.Now()},
|
||||
}
|
||||
|
||||
// Apply the same sort as GetTriageQueue.
|
||||
sortTriageQueue(queue)
|
||||
|
||||
expected := []string{"high", "medium", "low", "none"}
|
||||
for i, item := range queue {
|
||||
if item.Title != expected[i] {
|
||||
t.Errorf("queue[%d].Title = %q, want %q", i, item.Title, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sortTriageQueue is a test helper applying the same sort as GetTriageQueue.
|
||||
func sortTriageQueue(queue []TriageItem) {
|
||||
for i := 0; i < len(queue); i++ {
|
||||
for j := i + 1; j < len(queue); j++ {
|
||||
pi := priorityScore(queue[i].Labels)
|
||||
pj := priorityScore(queue[j].Labels)
|
||||
if pj < pi || (pj == pi && queue[j].UpdatedAt.After(queue[i].UpdatedAt)) {
|
||||
queue[i], queue[j] = queue[j], queue[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user