From e1e7aa64ca2fa9bd6e1270450ad129d7a3de5bcb Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 04:07:43 +0000 Subject: [PATCH] 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) --- internal/gitea/.gitkeep | 0 internal/gitea/client.go | 625 ++++++++++++++++++++++++++++++++++ internal/gitea/client_test.go | 215 ++++++++++++ 3 files changed, 840 insertions(+) delete mode 100644 internal/gitea/.gitkeep create mode 100644 internal/gitea/client.go create mode 100644 internal/gitea/client_test.go diff --git a/internal/gitea/.gitkeep b/internal/gitea/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/gitea/client.go b/internal/gitea/client.go new file mode 100644 index 0000000..c56c2af --- /dev/null +++ b/internal/gitea/client.go @@ -0,0 +1,625 @@ +// Package gitea provides an aggregation layer over the Gitea API, +// supporting concurrent fetching across multiple organizations and repos +// with in-memory caching. +package gitea + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "sync" + "time" +) + +// Client wraps the Gitea API with aggregation and caching capabilities. +type Client struct { + baseURL string + httpClient *http.Client + + mu sync.RWMutex + cache map[string]*cacheEntry + + // maxConcurrent controls the semaphore size for parallel API calls. + maxConcurrent int + // cacheTTL controls how long cache entries remain valid. + cacheTTL time.Duration +} + +type cacheEntry struct { + data interface{} + expiresAt time.Time +} + +// Org represents a Gitea organization. +type Org struct { + Name string `json:"username"` + FullName string `json:"full_name"` + Description string `json:"description"` + AvatarURL string `json:"avatar_url"` +} + +// Repo represents a Gitea repository. +type Repo struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` + HTMLURL string `json:"html_url"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Issue represents a Gitea issue. +type Issue struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Labels []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + } `json:"labels"` + Assignee *struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } `json:"assignee"` + Assignees []struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } `json:"assignees"` + HTMLURL string `json:"html_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RepoOwner string `json:"-"` // populated after fetch + RepoName string `json:"-"` // populated after fetch +} + +// PullRequest represents a Gitea pull request. +type PullRequest struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Labels []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + } `json:"labels"` + User *struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + } `json:"user"` + Mergeable bool `json:"mergeable"` + HTMLURL string `json:"html_url"` + DiffURL string `json:"diff_url"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RepoOwner string `json:"-"` // populated after fetch + RepoName string `json:"-"` // populated after fetch +} + +// TriageItem represents an item in the triage queue. +type TriageItem struct { + Type string // "issue" or "pull" + RepoOwner string + RepoName string + Number int64 + Title string + HTMLURL string + Labels []string + UpdatedAt time.Time +} + +// NewClient creates a new Gitea API client. +func NewClient(baseURL string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + cache: make(map[string]*cacheEntry), + maxConcurrent: 5, + cacheTTL: 30 * time.Second, + } +} + +// doRequest performs an authenticated HTTP request to the Gitea API. +func (c *Client) doRequest(ctx context.Context, token, method, path string, body io.Reader) (*http.Response, error) { + url := c.baseURL + "/api/v1" + path + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + + if resp.StatusCode >= 400 { + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) + } + + return resp, nil +} + +// getFromCache returns cached data if still valid. +func (c *Client) getFromCache(key string) (interface{}, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + entry, ok := c.cache[key] + if !ok || time.Now().After(entry.expiresAt) { + return nil, false + } + return entry.data, true +} + +// setCache stores data in cache with TTL. +func (c *Client) setCache(key string, data interface{}) { + c.mu.Lock() + defer c.mu.Unlock() + + c.cache[key] = &cacheEntry{ + data: data, + expiresAt: time.Now().Add(c.cacheTTL), + } +} + +// invalidateCache removes entries matching the given prefix. +func (c *Client) invalidateCache(prefix string) { + c.mu.Lock() + defer c.mu.Unlock() + + for k := range c.cache { + if strings.HasPrefix(k, prefix) { + delete(c.cache, k) + } + } +} + +// InvalidateAll clears the entire cache (called on write operations). +func (c *Client) InvalidateAll() { + c.mu.Lock() + defer c.mu.Unlock() + c.cache = make(map[string]*cacheEntry) +} + +// ListOrgs returns the organizations the authenticated user belongs to. +func (c *Client) ListOrgs(ctx context.Context, token string) ([]Org, error) { + cacheKey := "orgs" + if cached, ok := c.getFromCache(cacheKey); ok { + return cached.([]Org), nil + } + + resp, err := c.doRequest(ctx, token, http.MethodGet, "/user/orgs?limit=50", nil) + if err != nil { + return nil, fmt.Errorf("listing orgs: %w", err) + } + defer resp.Body.Close() + + var orgs []Org + if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil { + return nil, fmt.Errorf("decoding orgs: %w", err) + } + + c.setCache(cacheKey, orgs) + return orgs, nil +} + +// ListOrgRepos returns all repositories for a given organization. +func (c *Client) ListOrgRepos(ctx context.Context, token, org string) ([]Repo, error) { + cacheKey := fmt.Sprintf("repos-%s", org) + if cached, ok := c.getFromCache(cacheKey); ok { + return cached.([]Repo), nil + } + + var allRepos []Repo + page := 1 + for { + path := fmt.Sprintf("/orgs/%s/repos?limit=50&page=%d", org, page) + resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("listing repos for %s: %w", org, err) + } + + var repos []Repo + if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("decoding repos: %w", err) + } + resp.Body.Close() + + if len(repos) == 0 { + break + } + allRepos = append(allRepos, repos...) + if len(repos) < 50 { + break + } + page++ + } + + c.setCache(cacheKey, allRepos) + return allRepos, nil +} + +// ListOrgsAndRepos returns a map of org name to repos for all orgs the user belongs to. +func (c *Client) ListOrgsAndRepos(ctx context.Context, token string) (map[string][]Repo, error) { + orgs, err := c.ListOrgs(ctx, token) + if err != nil { + return nil, err + } + + result := make(map[string][]Repo) + var mu sync.Mutex + sem := make(chan struct{}, c.maxConcurrent) + var wg sync.WaitGroup + var firstErr error + + for _, org := range orgs { + wg.Add(1) + go func(orgName string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + repos, err := c.ListOrgRepos(ctx, token, orgName) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return + } + + mu.Lock() + result[orgName] = repos + mu.Unlock() + }(org.Name) + } + + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + return result, nil +} + +// ListAllIssues fetches all open issues across all repos in the given orgs, +// using concurrent requests with a semaphore. +func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string) ([]Issue, error) { + cacheKey := fmt.Sprintf("issues-%s", strings.Join(orgs, ",")) + if cached, ok := c.getFromCache(cacheKey); ok { + return cached.([]Issue), nil + } + + // First, collect all repos for the given orgs. + var allRepos []Repo + for _, org := range orgs { + repos, err := c.ListOrgRepos(ctx, token, org) + if err != nil { + return nil, fmt.Errorf("listing repos for %s: %w", org, err) + } + allRepos = append(allRepos, repos...) + } + + // Fan out issue fetching across repos. + var allIssues []Issue + var mu sync.Mutex + sem := make(chan struct{}, c.maxConcurrent) + var wg sync.WaitGroup + var firstErr error + + for _, repo := range allRepos { + wg.Add(1) + go func(r Repo) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + path := fmt.Sprintf("/repos/%s/issues?state=open&type=issues&limit=50", r.FullName) + resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = fmt.Errorf("fetching issues for %s: %w", r.FullName, err) + } + mu.Unlock() + return + } + defer resp.Body.Close() + + var issues []Issue + if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { + mu.Lock() + if firstErr == nil { + firstErr = fmt.Errorf("decoding issues for %s: %w", r.FullName, err) + } + mu.Unlock() + return + } + + // Tag each issue with repo info. + for i := range issues { + issues[i].RepoOwner = r.Owner.Login + issues[i].RepoName = r.Name + } + + mu.Lock() + allIssues = append(allIssues, issues...) + mu.Unlock() + }(repo) + } + + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + + // Sort by updated time, newest first. + sort.Slice(allIssues, func(i, j int) bool { + return allIssues[i].UpdatedAt.After(allIssues[j].UpdatedAt) + }) + + c.setCache(cacheKey, allIssues) + return allIssues, nil +} + +// ListAllPullRequests fetches all open PRs across all repos in the given orgs. +func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string) ([]PullRequest, error) { + cacheKey := fmt.Sprintf("pulls-%s", strings.Join(orgs, ",")) + if cached, ok := c.getFromCache(cacheKey); ok { + return cached.([]PullRequest), nil + } + + var allRepos []Repo + for _, org := range orgs { + repos, err := c.ListOrgRepos(ctx, token, org) + if err != nil { + return nil, fmt.Errorf("listing repos for %s: %w", org, err) + } + allRepos = append(allRepos, repos...) + } + + var allPRs []PullRequest + var mu sync.Mutex + sem := make(chan struct{}, c.maxConcurrent) + var wg sync.WaitGroup + var firstErr error + + for _, repo := range allRepos { + wg.Add(1) + go func(r Repo) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + path := fmt.Sprintf("/repos/%s/pulls?state=open&limit=50", r.FullName) + resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = fmt.Errorf("fetching PRs for %s: %w", r.FullName, err) + } + mu.Unlock() + return + } + defer resp.Body.Close() + + var prs []PullRequest + if err := json.NewDecoder(resp.Body).Decode(&prs); err != nil { + mu.Lock() + if firstErr == nil { + firstErr = fmt.Errorf("decoding PRs for %s: %w", r.FullName, err) + } + mu.Unlock() + return + } + + for i := range prs { + prs[i].RepoOwner = r.Owner.Login + prs[i].RepoName = r.Name + } + + mu.Lock() + allPRs = append(allPRs, prs...) + mu.Unlock() + }(repo) + } + + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + + sort.Slice(allPRs, func(i, j int) bool { + return allPRs[i].UpdatedAt.After(allPRs[j].UpdatedAt) + }) + + c.setCache(cacheKey, allPRs) + return allPRs, nil +} + +// GetTriageQueue returns unassigned issues and PRs needing review, sorted by priority. +func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string) ([]TriageItem, error) { + issues, err := c.ListAllIssues(ctx, token, orgs) + if err != nil { + return nil, fmt.Errorf("fetching issues for triage: %w", err) + } + + prs, err := c.ListAllPullRequests(ctx, token, orgs) + if err != nil { + return nil, fmt.Errorf("fetching PRs for triage: %w", err) + } + + var queue []TriageItem + + // Add unassigned issues. + for _, issue := range issues { + if issue.Assignee == nil && len(issue.Assignees) == 0 { + var labels []string + for _, l := range issue.Labels { + labels = append(labels, l.Name) + } + queue = append(queue, TriageItem{ + Type: "issue", + RepoOwner: issue.RepoOwner, + RepoName: issue.RepoName, + Number: issue.Number, + Title: issue.Title, + HTMLURL: issue.HTMLURL, + Labels: labels, + UpdatedAt: issue.UpdatedAt, + }) + } + } + + // Add PRs (all open PRs may need review attention). + for _, pr := range prs { + var labels []string + for _, l := range pr.Labels { + labels = append(labels, l.Name) + } + queue = append(queue, TriageItem{ + Type: "pull", + RepoOwner: pr.RepoOwner, + RepoName: pr.RepoName, + Number: pr.Number, + Title: pr.Title, + HTMLURL: pr.HTMLURL, + Labels: labels, + UpdatedAt: pr.UpdatedAt, + }) + } + + // Sort by priority labels (P1 > P2 > P3 > no priority), then by updated time. + sort.Slice(queue, func(i, j int) bool { + pi := priorityScore(queue[i].Labels) + pj := priorityScore(queue[j].Labels) + if pi != pj { + return pi < pj // lower score = higher priority + } + return queue[i].UpdatedAt.After(queue[j].UpdatedAt) + }) + + return queue, nil +} + +// CreateIssue creates a new issue in the specified repository. +func (c *Client) CreateIssue(ctx context.Context, token, owner, repo, title, body string, labels []int64) (*Issue, error) { + payload := map[string]interface{}{ + "title": title, + "body": body, + } + if len(labels) > 0 { + payload["labels"] = labels + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshaling issue: %w", err) + } + + path := fmt.Sprintf("/repos/%s/%s/issues", owner, repo) + resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(jsonData))) + if err != nil { + return nil, fmt.Errorf("creating issue: %w", err) + } + defer resp.Body.Close() + + var issue Issue + if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { + return nil, fmt.Errorf("decoding created issue: %w", err) + } + + issue.RepoOwner = owner + issue.RepoName = repo + + c.InvalidateAll() // Invalidate cache after write. + return &issue, nil +} + +// ApplyLabel adds a label to an issue. +func (c *Client) ApplyLabel(ctx context.Context, token, owner, repo string, index int64, labelIDs []int64) error { + payload := map[string]interface{}{ + "labels": labelIDs, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshaling labels: %w", err) + } + + path := fmt.Sprintf("/repos/%s/%s/issues/%d/labels", owner, repo, index) + resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(jsonData))) + if err != nil { + return fmt.Errorf("applying labels: %w", err) + } + resp.Body.Close() + + c.InvalidateAll() + return nil +} + +// SubmitReview submits a review on a pull request. +func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, index int64, reviewType, body string) error { + payload := map[string]interface{}{ + "event": reviewType, // "APPROVED", "REQUEST_CHANGES", "COMMENT" + "body": body, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshaling review: %w", err) + } + + path := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, index) + resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(jsonData))) + if err != nil { + return fmt.Errorf("submitting review: %w", err) + } + resp.Body.Close() + + c.InvalidateAll() + return nil +} + +// priorityScore returns a numeric score for sorting (lower = higher priority). +func priorityScore(labels []string) int { + for _, l := range labels { + switch l { + case "P1": + return 1 + case "P2": + return 2 + case "P3": + return 3 + } + } + return 4 // no priority label +} diff --git a/internal/gitea/client_test.go b/internal/gitea/client_test.go new file mode 100644 index 0000000..2d45c18 --- /dev/null +++ b/internal/gitea/client_test.go @@ -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] + } + } + } +}