mirror of
https://github.com/tiennm99/awesome-coding-agents.git
synced 2026-08-31 22:23:00 +00:00
Review fixes: - applyMigrations resolves chained renames deterministically (cycle guard) - GraphQL errors name the failing owner/repo, not the alias - sanitizeCell escapes backslash + angle brackets (README md/HTML injection) - timeNow/graphqlURL seams; new httptest fetch tests, site + delta tests (coverage 30% -> 65.6%) Features: - delta30d computed from history; dashboard gains sortable D30d column - README shows Top 7-day mover line - 'go run . -check' validates agents.yml offline (dupes, categories, name patterns); wired into CI for contributor PRs - isArchived + rename drift warnings in daily run; archived badge on dashboard (E2E found 3 archived repos: gpt-engineer, void, Roo-Code)
263 lines
7.7 KiB
Go
263 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Stat struct {
|
|
// CanonicalKey is owner/repo from agents.yml — stable across renames.
|
|
CanonicalKey string
|
|
Owner string
|
|
Repo string
|
|
Category string
|
|
Notes string
|
|
Description string
|
|
Stars int
|
|
Language string
|
|
PushedAt time.Time
|
|
URL string
|
|
NameWithOwner string
|
|
IsArchived bool
|
|
}
|
|
|
|
type repoNode struct {
|
|
StargazerCount int `json:"stargazerCount"`
|
|
Description string `json:"description"`
|
|
PrimaryLanguage *struct {
|
|
Name string `json:"name"`
|
|
} `json:"primaryLanguage"`
|
|
PushedAt time.Time `json:"pushedAt"`
|
|
URL string `json:"url"`
|
|
NameWithOwner string `json:"nameWithOwner"`
|
|
IsArchived bool `json:"isArchived"`
|
|
}
|
|
|
|
type graphQLResponse struct {
|
|
Data map[string]*repoNode `json:"data"`
|
|
Errors []struct {
|
|
Message string `json:"message"`
|
|
Path []any `json:"path"`
|
|
} `json:"errors"`
|
|
}
|
|
|
|
const repoFields = `
|
|
stargazerCount
|
|
description
|
|
primaryLanguage { name }
|
|
pushedAt
|
|
url
|
|
nameWithOwner
|
|
isArchived
|
|
`
|
|
|
|
// httpClient has a timeout to prevent hung workflow jobs.
|
|
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
|
|
|
// graphqlURL is a seam for tests: production always talks to GitHub, but
|
|
// tests point this at an httptest server to exercise the whole fetch path
|
|
// (chunking, alias offsets, missing-node, GraphQL-error handling) without
|
|
// a network call or token.
|
|
var graphqlURL = "https://api.github.com/graphql"
|
|
|
|
// chunkSize is the max aliases per GraphQL request (GitHub node-limit safety margin).
|
|
const chunkSize = 50
|
|
|
|
// maxRetries and retry backoff for transient HTTP/network errors.
|
|
const maxRetries = 3
|
|
|
|
var retryBackoff = []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second}
|
|
|
|
// fetchStats queries GitHub GraphQL in chunks of up to chunkSize repos per
|
|
// request. Returns an error if any GraphQL errors are present OR if any
|
|
// requested repo is missing from the response — better to fail loud than
|
|
// silently publish a shorter README.
|
|
func fetchStats(token string, agents []Agent) ([]Stat, error) {
|
|
collected := make(map[string]*repoNode, len(agents))
|
|
|
|
for start := 0; start < len(agents); start += chunkSize {
|
|
end := start + chunkSize
|
|
if end > len(agents) {
|
|
end = len(agents)
|
|
}
|
|
chunk := agents[start:end]
|
|
|
|
nodes, err := fetchChunk(token, chunk, start)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for k, v := range nodes {
|
|
collected[k] = v
|
|
}
|
|
}
|
|
|
|
stats := make([]Stat, 0, len(agents))
|
|
for i, a := range agents {
|
|
alias := fmt.Sprintf("r%d", i)
|
|
node := collected[alias]
|
|
if node == nil {
|
|
return nil, fmt.Errorf("repo %s/%s (alias %s) missing from GraphQL response — deleted, private, or renamed?", a.Owner, a.Repo, alias)
|
|
}
|
|
lang := ""
|
|
if node.PrimaryLanguage != nil {
|
|
lang = node.PrimaryLanguage.Name
|
|
}
|
|
|
|
// Drift detection: surface renames/archival as GitHub Actions run
|
|
// annotations so a human notices without polling every repo by hand.
|
|
canonicalKey := a.Owner + "/" + a.Repo
|
|
if node.NameWithOwner != "" && !strings.EqualFold(node.NameWithOwner, canonicalKey) {
|
|
fmt.Printf("::warning::repo %s renamed to %s — update data/agents.yml and add a canonicalKeyMigrations entry\n", canonicalKey, node.NameWithOwner)
|
|
}
|
|
if node.IsArchived {
|
|
fmt.Printf("::warning::repo %s is archived — consider removing or annotating\n", canonicalKey)
|
|
}
|
|
|
|
stats = append(stats, Stat{
|
|
CanonicalKey: canonicalKey,
|
|
Owner: a.Owner,
|
|
Repo: a.Repo,
|
|
Category: a.Category,
|
|
Notes: a.Notes,
|
|
Description: node.Description,
|
|
Stars: node.StargazerCount,
|
|
Language: lang,
|
|
PushedAt: node.PushedAt,
|
|
URL: node.URL,
|
|
NameWithOwner: node.NameWithOwner,
|
|
IsArchived: node.IsArchived,
|
|
})
|
|
}
|
|
|
|
// Sort by stars descending. Ties are ordered by CanonicalKey for determinism
|
|
// regardless of map-iteration or agents.yml order.
|
|
sort.Slice(stats, func(i, j int) bool {
|
|
if stats[i].Stars != stats[j].Stars {
|
|
return stats[i].Stars > stats[j].Stars
|
|
}
|
|
return stats[i].CanonicalKey < stats[j].CanonicalKey
|
|
})
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// fetchChunk sends one GraphQL request for a slice of agents. aliasOffset
|
|
// ensures alias names (r0, r1, …) are globally unique across chunks.
|
|
func fetchChunk(token string, agents []Agent, aliasOffset int) (map[string]*repoNode, error) {
|
|
var b strings.Builder
|
|
b.WriteString("query {\n")
|
|
for i, a := range agents {
|
|
fmt.Fprintf(&b, " r%d: repository(owner: %q, name: %q) {%s}\n", aliasOffset+i, a.Owner, a.Repo, repoFields)
|
|
}
|
|
b.WriteString("}\n")
|
|
|
|
body, err := json.Marshal(map[string]string{"query": b.String()})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
raw, statusCode, err := doWithRetry(token, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if statusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("graphql HTTP %d: %s", statusCode, raw)
|
|
}
|
|
|
|
var out graphQLResponse
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w (body=%s)", err, raw)
|
|
}
|
|
|
|
// Treat any GraphQL-level error as fatal — partial data silently shrinks
|
|
// the README and corrupts future delta math.
|
|
if len(out.Errors) > 0 {
|
|
msgs := make([]string, len(out.Errors))
|
|
for i, e := range out.Errors {
|
|
msgs[i] = fmt.Sprintf("%s (%s)", e.Message, describeErrorPath(e.Path, agents, aliasOffset))
|
|
}
|
|
return nil, fmt.Errorf("graphql errors: %s", strings.Join(msgs, "; "))
|
|
}
|
|
|
|
return out.Data, nil
|
|
}
|
|
|
|
// describeErrorPath translates a GraphQL error path such as
|
|
// ["r17", "stargazerCount"] into a human-readable repo reference, e.g.
|
|
// "repo foo/bar (alias r17)", using the alias→Agent mapping for this chunk
|
|
// (aliasOffset + local index). Falls back to the raw path if the alias
|
|
// can't be resolved (unexpected path shape or out-of-range index).
|
|
func describeErrorPath(path []any, agents []Agent, aliasOffset int) string {
|
|
if len(path) == 0 {
|
|
return "path=[]"
|
|
}
|
|
aliasStr, ok := path[0].(string)
|
|
if !ok || !strings.HasPrefix(aliasStr, "r") {
|
|
return fmt.Sprintf("path=%v", path)
|
|
}
|
|
idx, err := strconv.Atoi(aliasStr[1:])
|
|
if err != nil {
|
|
return fmt.Sprintf("path=%v", path)
|
|
}
|
|
local := idx - aliasOffset
|
|
if local < 0 || local >= len(agents) {
|
|
return fmt.Sprintf("path=%v (alias %s)", path, aliasStr)
|
|
}
|
|
a := agents[local]
|
|
return fmt.Sprintf("repo %s/%s (alias %s)", a.Owner, a.Repo, aliasStr)
|
|
}
|
|
|
|
// doWithRetry executes the GraphQL POST with exponential backoff on transient
|
|
// errors (network failures, HTTP 5xx, HTTP 429). 4xx other than 429 are not
|
|
// retried.
|
|
func doWithRetry(token string, body []byte) ([]byte, int, error) {
|
|
var lastErr error
|
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
log.Printf("retry attempt %d after %v: %v", attempt, retryBackoff[attempt-1], lastErr)
|
|
time.Sleep(retryBackoff[attempt-1])
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", graphqlURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "awesome-coding-agents-updater")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue // network error — retry
|
|
}
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close() // body fully read (or read failed); close error is not actionable
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("read body: %w", err)
|
|
continue
|
|
}
|
|
|
|
sc := resp.StatusCode
|
|
if sc == http.StatusOK {
|
|
return raw, sc, nil
|
|
}
|
|
if sc == http.StatusTooManyRequests || sc >= 500 {
|
|
lastErr = fmt.Errorf("HTTP %d: %s", sc, raw)
|
|
continue // retryable
|
|
}
|
|
// 4xx (except 429) — not retryable
|
|
return raw, sc, nil
|
|
}
|
|
return nil, 0, fmt.Errorf("all %d attempts failed; last error: %w", maxRetries, lastErr)
|
|
}
|