Files
awesome-coding-agents/github.go
T

308 lines
9.6 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
Tags []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"
// staleWarnAfter is how long a tracked repo may go without a push before the
// updater flags it for review. It mirrors the review window in the README's
// inclusion criteria; dropping an entry stays a human decision, so this only
// annotates the run.
const staleWarnAfter = 90 * 24 * time.Hour
// minStars is the hard inclusion floor from the README's criteria. Unlike the
// staleness window, this is not a judgement call: an entry below the floor is
// dropped from the published ranking, so the list can never show a repo that
// does not meet it. It lives here rather than in -check because star counts
// need the API and -check runs offline.
const minStars = 1000
// 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 and staleness 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)
}
switch {
case node.IsArchived:
// Archived already implies no further pushes; one warning is enough.
fmt.Printf("::warning::repo %s is archived — consider removing or annotating\n", canonicalKey)
case !node.PushedAt.IsZero() && timeNow().Sub(node.PushedAt) > staleWarnAfter:
days := int(timeNow().Sub(node.PushedAt).Hours() / 24)
fmt.Printf("::warning::repo %s has no push in %d days — review against the maintenance criterion\n", canonicalKey, days)
}
desc := node.Description
if a.Description != "" {
desc = a.Description
}
stats = append(stats, Stat{
CanonicalKey: canonicalKey,
Owner: a.Owner,
Repo: a.Repo,
Tags: a.Tags,
Notes: a.Notes,
Description: desc,
Stars: node.StargazerCount,
Language: lang,
PushedAt: node.PushedAt,
URL: node.URL,
NameWithOwner: node.NameWithOwner,
IsArchived: node.IsArchived,
})
}
sortStats(stats)
return stats, nil
}
// sortStats orders the ranking by stars descending. Ties break on CanonicalKey
// for determinism regardless of map-iteration or agents.yml order. Shared with
// the build step, which re-ranks from committed metadata.
func sortStats(stats []Stat) {
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
})
}
// 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-ai-dev-tools-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)
}
// enforceStarFloor drops entries below minStars and annotates each one as a run
// error. Dropping rather than failing the run keeps one below-floor entry from
// blocking the refresh of every other repo, while the ::error:: annotation
// makes the removal impossible to miss in the Actions log.
func enforceStarFloor(stats []Stat) []Stat {
kept := make([]Stat, 0, len(stats))
for _, s := range stats {
if s.Stars < minStars {
fmt.Printf("::error::repo %s has %d stars, below the %d minimum — dropped from the ranking; remove its entry from data/agents.yml\n", s.CanonicalKey, s.Stars, minStars)
continue
}
kept = append(kept, s)
}
return kept
}