mirror of
https://github.com/tiennm99/awesome-coding-agents.git
synced 2026-08-21 08:25:00 +00:00
feat: momentum metrics, agents.yml validation, drift detection, hardening (#9)
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)
This commit is contained in:
@@ -9,6 +9,8 @@ on:
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'data/agents.yml'
|
||||
- 'templates/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,6 +29,7 @@ jobs:
|
||||
- run: go vet ./...
|
||||
- run: go test ./...
|
||||
- run: go build ./...
|
||||
- run: go run . -check
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -5,23 +5,24 @@
|
||||
|
||||
📊 **[Interactive dashboard with star-history charts →](https://tiennm99.github.io/awesome-coding-agents/)**
|
||||
|
||||
**Last updated:** 2026-08-08 19:48 UTC · **Tracked:** 29 repos
|
||||
**Last updated:** 2026-08-08 20:14 UTC · **Tracked:** 29 repos
|
||||
**Top 7-day mover:** [anomalyco/opencode](https://github.com/anomalyco/opencode) (+3360 stars)
|
||||
|
||||
| # | Repo | Stars | Δ7d | Language | Last push | Description |
|
||||
|---|------|------:|----:|----------|-----------|-------------|
|
||||
| 1 | [anomalyco/opencode](https://github.com/anomalyco/opencode) | 195.1k | +3356 | TypeScript | 2026-08-08 | The open source coding agent. |
|
||||
| 2 | [anthropics/claude-code](https://github.com/anthropics/claude-code) | 140.7k | +882 | Python | 2026-08-08 | Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands. |
|
||||
| 1 | [anomalyco/opencode](https://github.com/anomalyco/opencode) | 195.1k | +3360 | TypeScript | 2026-08-08 | The open source coding agent. |
|
||||
| 2 | [anthropics/claude-code](https://github.com/anthropics/claude-code) | 140.7k | +884 | Python | 2026-08-08 | Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands. |
|
||||
| 3 | [google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | 106.4k | +136 | TypeScript | 2026-08-08 | An open-source AI agent that brings the power of Gemini directly into your terminal. |
|
||||
| 4 | [openai/codex](https://github.com/openai/codex) | 104.8k | +1842 | Rust | 2026-08-08 | Lightweight coding agent that runs in your terminal |
|
||||
| 5 | [zed-industries/zed](https://github.com/zed-industries/zed) | 88.3k | +433 | Rust | 2026-08-08 | Code at the speed of thought – Zed is a high-performance, multiplayer code editor from the creators of Atom and Tree-sitter. |
|
||||
| 4 | [openai/codex](https://github.com/openai/codex) | 104.8k | +1844 | Rust | 2026-08-08 | Lightweight coding agent that runs in your terminal |
|
||||
| 5 | [zed-industries/zed](https://github.com/zed-industries/zed) | 88.3k | +434 | Rust | 2026-08-08 | Code at the speed of thought – Zed is a high-performance, multiplayer code editor from the creators of Atom and Tree-sitter. |
|
||||
| 6 | [earendil-works/pi](https://github.com/earendil-works/pi) | 85.6k | — | TypeScript | 2026-08-08 | AI agent toolkit: unified LLM API, agent loop, TUI, coding agent CLI |
|
||||
| 7 | [OpenHands/OpenHands](https://github.com/OpenHands/OpenHands) | 83.5k | — | TypeScript | 2026-08-08 | 🙌 OpenHands: AI-Driven Development |
|
||||
| 8 | [cline/cline](https://github.com/cline/cline) | 65.9k | +543 | TypeScript | 2026-08-08 | Autonomous coding agent as an SDK, IDE extension, or CLI assistant. |
|
||||
| 9 | [warpdotdev/warp](https://github.com/warpdotdev/warp) | 64.1k | — | Rust | 2026-08-08 | Warp is an agentic development environment, born out of the terminal. |
|
||||
| 10 | [AntonOsika/gpt-engineer](https://github.com/AntonOsika/gpt-engineer) | 55.2k | -17 | Python | 2025-05-14 | CLI platform to experiment with codegen. Precursor to: https://lovable.dev |
|
||||
| 11 | [aaif-goose/goose](https://github.com/aaif-goose/goose) | 52.6k | +520 | Rust | 2026-08-08 | an open source, extensible AI agent that goes beyond code suggestions - install, execute, edit, and test with any LLM |
|
||||
| 11 | [aaif-goose/goose](https://github.com/aaif-goose/goose) | 52.6k | +522 | Rust | 2026-08-08 | an open source, extensible AI agent that goes beyond code suggestions - install, execute, edit, and test with any LLM |
|
||||
| 12 | [Aider-AI/aider](https://github.com/Aider-AI/aider) | 48.1k | +210 | Python | 2026-05-22 | aider is AI pair programming in your terminal |
|
||||
| 13 | [continuedev/continue](https://github.com/continuedev/continue) | 35.4k | +143 | TypeScript | 2026-08-08 | open-source coding agent |
|
||||
| 13 | [continuedev/continue](https://github.com/continuedev/continue) | 35.4k | +144 | TypeScript | 2026-08-08 | open-source coding agent |
|
||||
| 14 | [TabbyML/tabby](https://github.com/TabbyML/tabby) | 33.8k | +18 | Rust | 2026-06-30 | Self-hosted AI coding assistant |
|
||||
| 15 | [Pythagora-io/gpt-pilot](https://github.com/Pythagora-io/gpt-pilot) | 33.7k | — | Python | 2026-06-18 | The first real AI developer |
|
||||
| 16 | [voideditor/void](https://github.com/voideditor/void) | 28.9k | -6 | TypeScript | 2026-06-02 | |
|
||||
@@ -65,6 +66,8 @@ agents:
|
||||
|
||||
Open a PR. The next daily run picks it up automatically.
|
||||
|
||||
PRs are validated automatically by CI (`go run . -check`): owner/repo must be non-empty and look like a real GitHub slug, category must be one of the values above, and duplicates (case-insensitive) are rejected.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
+1
-1
@@ -82,4 +82,4 @@
|
||||
{"date":"2026-08-05","stars":{"Aider-AI/aider":47944,"AntonOsika/gpt-engineer":55160,"RooCodeInc/Roo-Code":24359,"SWE-agent/SWE-agent":19995,"TabbyML/tabby":33816,"aaif-goose/goose":52264,"anomalyco/opencode":193387,"anthropics/claude-code":140256,"charmbracelet/crush":27074,"cline/cline":65646,"continuedev/continue":35320,"google-gemini/gemini-cli":106364,"kortix-ai/suna":20067,"openai/codex":103966,"plandex-ai/plandex":15568,"stackblitz/bolt.new":16491,"voideditor/void":28862,"yetone/avante.nvim":18098,"zed-industries/zed":88043}}
|
||||
{"date":"2026-08-06","stars":{"Aider-AI/aider":47974,"AntonOsika/gpt-engineer":55158,"RooCodeInc/Roo-Code":24354,"SWE-agent/SWE-agent":20004,"TabbyML/tabby":33821,"aaif-goose/goose":52423,"anomalyco/opencode":193870,"anthropics/claude-code":140402,"charmbracelet/crush":27105,"cline/cline":65718,"continuedev/continue":35339,"google-gemini/gemini-cli":106382,"kortix-ai/suna":20073,"openai/codex":104209,"plandex-ai/plandex":15571,"stackblitz/bolt.new":16496,"voideditor/void":28862,"yetone/avante.nvim":18102,"zed-industries/zed":88094}}
|
||||
{"date":"2026-08-07","stars":{"Aider-AI/aider":48001,"AntonOsika/gpt-engineer":55157,"RooCodeInc/Roo-Code":24355,"SWE-agent/SWE-agent":20015,"TabbyML/tabby":33821,"aaif-goose/goose":52477,"anomalyco/opencode":194361,"anthropics/claude-code":140514,"charmbracelet/crush":27134,"cline/cline":65789,"continuedev/continue":35363,"google-gemini/gemini-cli":106400,"kortix-ai/suna":20079,"openai/codex":104447,"plandex-ai/plandex":15573,"stackblitz/bolt.new":16500,"voideditor/void":28861,"yetone/avante.nvim":18108,"zed-industries/zed":88160}}
|
||||
{"date":"2026-08-08","stars":{"Aider-AI/aider":48058,"AntonOsika/gpt-engineer":55156,"Kilo-Org/kilocode":26778,"OpenHands/OpenHands":83480,"Pythagora-io/gpt-pilot":33713,"QwenLM/qwen-code":26857,"RooCodeInc/Roo-Code":24354,"SWE-agent/SWE-agent":20025,"TabbyML/tabby":33822,"aaif-goose/goose":52559,"anomalyco/opencode":195063,"anthropics/claude-code":140716,"bytedance/trae-agent":11993,"charmbracelet/crush":27186,"cline/cline":65884,"continuedev/continue":35390,"dyad-sh/dyad":21141,"earendil-works/pi":85578,"github/copilot-cli":11070,"google-gemini/gemini-cli":106423,"kortix-ai/suna":20087,"onlook-dev/onlook":26439,"openai/codex":104789,"plandex-ai/plandex":15577,"stackblitz/bolt.new":16503,"voideditor/void":28860,"warpdotdev/warp":64073,"yetone/avante.nvim":18110,"zed-industries/zed":88276}}
|
||||
{"date":"2026-08-08","stars":{"Aider-AI/aider":48058,"AntonOsika/gpt-engineer":55156,"Kilo-Org/kilocode":26778,"OpenHands/OpenHands":83482,"Pythagora-io/gpt-pilot":33713,"QwenLM/qwen-code":26857,"RooCodeInc/Roo-Code":24354,"SWE-agent/SWE-agent":20025,"TabbyML/tabby":33822,"aaif-goose/goose":52561,"anomalyco/opencode":195067,"anthropics/claude-code":140718,"bytedance/trae-agent":11993,"charmbracelet/crush":27186,"cline/cline":65884,"continuedev/continue":35391,"dyad-sh/dyad":21141,"earendil-works/pi":85583,"github/copilot-cli":11070,"google-gemini/gemini-cli":106423,"kortix-ai/suna":20087,"onlook-dev/onlook":26439,"openai/codex":104791,"plandex-ai/plandex":15577,"stackblitz/bolt.new":16503,"voideditor/void":28860,"warpdotdev/warp":64075,"yetone/avante.nvim":18110,"zed-industries/zed":88277}}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -25,6 +26,7 @@ type Stat struct {
|
||||
PushedAt time.Time
|
||||
URL string
|
||||
NameWithOwner string
|
||||
IsArchived bool
|
||||
}
|
||||
|
||||
type repoNode struct {
|
||||
@@ -36,6 +38,7 @@ type repoNode struct {
|
||||
PushedAt time.Time `json:"pushedAt"`
|
||||
URL string `json:"url"`
|
||||
NameWithOwner string `json:"nameWithOwner"`
|
||||
IsArchived bool `json:"isArchived"`
|
||||
}
|
||||
|
||||
type graphQLResponse struct {
|
||||
@@ -53,11 +56,18 @@ const repoFields = `
|
||||
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
|
||||
|
||||
@@ -94,14 +104,25 @@ func fetchStats(token string, agents []Agent) ([]Stat, error) {
|
||||
alias := fmt.Sprintf("r%d", i)
|
||||
node := collected[alias]
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("repo %s/%s missing from GraphQL response", a.Owner, a.Repo)
|
||||
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: a.Owner + "/" + a.Repo,
|
||||
CanonicalKey: canonicalKey,
|
||||
Owner: a.Owner,
|
||||
Repo: a.Repo,
|
||||
Category: a.Category,
|
||||
@@ -112,6 +133,7 @@ func fetchStats(token string, agents []Agent) ([]Stat, error) {
|
||||
PushedAt: node.PushedAt,
|
||||
URL: node.URL,
|
||||
NameWithOwner: node.NameWithOwner,
|
||||
IsArchived: node.IsArchived,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,7 +182,7 @@ func fetchChunk(token string, agents []Agent, aliasOffset int) (map[string]*repo
|
||||
if len(out.Errors) > 0 {
|
||||
msgs := make([]string, len(out.Errors))
|
||||
for i, e := range out.Errors {
|
||||
msgs[i] = fmt.Sprintf("%s (path=%v)", e.Message, e.Path)
|
||||
msgs[i] = fmt.Sprintf("%s (%s)", e.Message, describeErrorPath(e.Path, agents, aliasOffset))
|
||||
}
|
||||
return nil, fmt.Errorf("graphql errors: %s", strings.Join(msgs, "; "))
|
||||
}
|
||||
@@ -168,6 +190,31 @@ func fetchChunk(token string, agents []Agent, aliasOffset int) (map[string]*repo
|
||||
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.
|
||||
@@ -179,7 +226,7 @@ func doWithRetry(token string, body []byte) ([]byte, int, error) {
|
||||
time.Sleep(retryBackoff[attempt-1])
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewReader(body))
|
||||
req, err := http.NewRequest("POST", graphqlURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// aliasRe extracts the numeric alias index from a GraphQL query fragment
|
||||
// like " r17: repository(owner: \"foo\", name: \"bar\") {...}".
|
||||
var aliasRe = regexp.MustCompile(`r(\d+):\s*repository`)
|
||||
|
||||
// withGraphQLURL points graphqlURL at srv for the duration of the test and
|
||||
// restores the original value on cleanup.
|
||||
func withGraphQLURL(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
orig := graphqlURL
|
||||
graphqlURL = srv.URL
|
||||
t.Cleanup(func() { graphqlURL = orig })
|
||||
}
|
||||
|
||||
func readGraphQLQuery(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Fatalf("unmarshal request body: %v (body=%s)", err, body)
|
||||
}
|
||||
return req.Query
|
||||
}
|
||||
|
||||
func TestFetchStats_ChunkingHappyPath(t *testing.T) {
|
||||
// 55 agents forces 2 chunks (50 + 5) — chunk-boundary code has zero
|
||||
// production coverage today (only 29 agents exist), so this is the
|
||||
// first exercise of the alias-offset math across a chunk boundary.
|
||||
const n = 55
|
||||
agents := make([]Agent, n)
|
||||
for i := range agents {
|
||||
agents[i] = Agent{Owner: "org", Repo: fmt.Sprintf("repo%02d", i), Category: "cli"}
|
||||
}
|
||||
|
||||
var requestCount int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
query := readGraphQLQuery(t, r)
|
||||
|
||||
data := map[string]any{}
|
||||
for _, m := range aliasRe.FindAllStringSubmatch(query, -1) {
|
||||
idx, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
t.Fatalf("parse alias index from %q: %v", m[0], err)
|
||||
}
|
||||
data["r"+m[1]] = map[string]any{
|
||||
"stargazerCount": 100 + idx,
|
||||
"description": "desc " + m[1],
|
||||
"primaryLanguage": map[string]string{"name": "Go"},
|
||||
"pushedAt": "2026-08-01T00:00:00Z",
|
||||
"url": fmt.Sprintf("https://github.com/org/repo%02d", idx),
|
||||
"nameWithOwner": fmt.Sprintf("org/repo%02d", idx),
|
||||
"isArchived": false,
|
||||
}
|
||||
}
|
||||
out, err := json.Marshal(map[string]any{"data": data})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal response: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if _, err := w.Write(out); err != nil {
|
||||
t.Fatalf("write response: %v", err)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
withGraphQLURL(t, srv)
|
||||
|
||||
stats, err := fetchStats("test-token", agents)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchStats: %v", err)
|
||||
}
|
||||
if requestCount != 2 {
|
||||
t.Errorf("expected 2 chunk requests (50 + 5), got %d", requestCount)
|
||||
}
|
||||
if len(stats) != n {
|
||||
t.Fatalf("expected %d stats, got %d", n, len(stats))
|
||||
}
|
||||
// Sorted descending by stars: repo54 (154 stars) must be first.
|
||||
if stats[0].NameWithOwner != "org/repo54" || stats[0].Stars != 154 {
|
||||
t.Errorf("expected top stat org/repo54 with 154 stars, got %s with %d", stats[0].NameWithOwner, stats[0].Stars)
|
||||
}
|
||||
// Spot-check a repo from the second chunk (alias offset 50) resolved
|
||||
// correctly — this is exactly the code path that has never run in
|
||||
// production (only 1 chunk is used with 29 agents).
|
||||
var found bool
|
||||
for _, s := range stats {
|
||||
if s.NameWithOwner == "org/repo52" {
|
||||
found = true
|
||||
if s.Stars != 152 {
|
||||
t.Errorf("org/repo52: expected 152 stars, got %d", s.Stars)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected org/repo52 (second chunk) in results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchStats_MissingNodeNamesTheRepo(t *testing.T) {
|
||||
agents := []Agent{
|
||||
{Owner: "foo", Repo: "bar0", Category: "cli"},
|
||||
{Owner: "foo", Repo: "bar1", Category: "cli"},
|
||||
{Owner: "foo", Repo: "bar2", Category: "cli"},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
query := readGraphQLQuery(t, r)
|
||||
data := map[string]any{}
|
||||
for _, m := range aliasRe.FindAllStringSubmatch(query, -1) {
|
||||
if m[1] == "2" {
|
||||
continue // simulate repo r2 (foo/bar2) deleted/private/renamed away
|
||||
}
|
||||
data["r"+m[1]] = map[string]any{
|
||||
"stargazerCount": 10,
|
||||
"nameWithOwner": "foo/bar" + m[1],
|
||||
}
|
||||
}
|
||||
out, _ := json.Marshal(map[string]any{"data": data}) // static map of strings/ints/bools never fails to marshal
|
||||
_, _ = w.Write(out) // httptest ResponseRecorder write error is not actionable in a test fake
|
||||
}))
|
||||
defer srv.Close()
|
||||
withGraphQLURL(t, srv)
|
||||
|
||||
_, err := fetchStats("test-token", agents)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing node, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "foo/bar2") {
|
||||
t.Errorf("expected error to name the missing repo foo/bar2, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "alias r2") {
|
||||
t.Errorf("expected error to name the alias r2, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchStats_GraphQLErrorNamesTheRepo(t *testing.T) {
|
||||
agents := []Agent{
|
||||
{Owner: "foo", Repo: "bar0", Category: "cli"},
|
||||
{Owner: "foo", Repo: "bar1", Category: "cli"},
|
||||
{Owner: "foo", Repo: "bar2", Category: "cli"},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]any{
|
||||
"data": nil,
|
||||
"errors": []map[string]any{
|
||||
{"message": "Could not resolve to a Repository.", "path": []any{"r2"}},
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(resp) // static map of strings/ints/bools never fails to marshal
|
||||
_, _ = w.Write(out) // httptest ResponseRecorder write error is not actionable in a test fake
|
||||
}))
|
||||
defer srv.Close()
|
||||
withGraphQLURL(t, srv)
|
||||
|
||||
_, err := fetchStats("test-token", agents)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for GraphQL-level error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "repo foo/bar2") {
|
||||
t.Errorf("expected error to name repo foo/bar2, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "alias r2") {
|
||||
t.Errorf("expected error to name alias r2, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Could not resolve to a Repository.") {
|
||||
t.Errorf("expected original GraphQL message preserved, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchStats_DriftWarnings(t *testing.T) {
|
||||
// Renamed and archived repos should not error the run — fetchStats
|
||||
// still succeeds and reports the drift as a warning (checked via
|
||||
// captured stdout) rather than failing the whole daily update.
|
||||
agents := []Agent{
|
||||
{Owner: "old-owner", Repo: "renamed-repo", Category: "cli"},
|
||||
{Owner: "org", Repo: "archived-repo", Category: "cli"},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"r0": map[string]any{
|
||||
"stargazerCount": 5,
|
||||
"nameWithOwner": "new-owner/renamed-repo",
|
||||
"isArchived": false,
|
||||
},
|
||||
"r1": map[string]any{
|
||||
"stargazerCount": 5,
|
||||
"nameWithOwner": "org/archived-repo",
|
||||
"isArchived": true,
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(map[string]any{"data": data}) // static map of strings/ints/bools never fails to marshal
|
||||
_, _ = w.Write(out) // httptest ResponseRecorder write error is not actionable in a test fake
|
||||
}))
|
||||
defer srv.Close()
|
||||
withGraphQLURL(t, srv)
|
||||
|
||||
stats, err := fetchStats("test-token", agents)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchStats: %v (drift should warn, not fail)", err)
|
||||
}
|
||||
if len(stats) != 2 {
|
||||
t.Fatalf("expected 2 stats, got %d", len(stats))
|
||||
}
|
||||
for _, s := range stats {
|
||||
if s.CanonicalKey == "org/archived-repo" && !s.IsArchived {
|
||||
t.Error("expected org/archived-repo Stat.IsArchived=true")
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-22
@@ -29,10 +29,16 @@ var canonicalKeyMigrations = map[string]string{
|
||||
"gpt-engineer-org/gpt-engineer": "AntonOsika/gpt-engineer",
|
||||
}
|
||||
|
||||
// timeNow is a seam for tests: production code always calls time.Now, but
|
||||
// tests can override this var to exercise fixed-calendar-date scenarios
|
||||
// (delta window boundaries, migration+delta combinations) deterministically.
|
||||
var timeNow = time.Now
|
||||
|
||||
// appendHistory persists today's snapshot and returns the full snapshot list
|
||||
// (oldest first, including today) plus the 7-day deltas per canonical key.
|
||||
func appendHistory(path string, stats []Stat) ([]Snapshot, map[string]int, error) {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
// (oldest first, including today) plus the 7-day and 30-day deltas per
|
||||
// canonical key.
|
||||
func appendHistory(path string, stats []Stat) (snapshots []Snapshot, deltas7 map[string]int, deltas30 map[string]int, err error) {
|
||||
today := timeNow().UTC().Format("2006-01-02")
|
||||
|
||||
// Key snapshots by canonical owner/repo from agents.yml, not by the
|
||||
// API-returned nameWithOwner, so renames don't orphan historical data.
|
||||
@@ -41,18 +47,22 @@ func appendHistory(path string, stats []Stat) ([]Snapshot, map[string]int, error
|
||||
current.Stars[s.CanonicalKey] = s.Stars
|
||||
}
|
||||
|
||||
snapshots, err := readSnapshots(path)
|
||||
history, err := readSnapshots(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
deltas := computeDeltas(snapshots, current)
|
||||
deltas7 = computeDeltas(history, current)
|
||||
deltas30 = computeDeltaOver(history, current, 30, 5)
|
||||
|
||||
// Drop any pre-existing snapshot for today, then append current.
|
||||
kept := slices.DeleteFunc(snapshots, func(s Snapshot) bool { return s.Date == today })
|
||||
kept := slices.DeleteFunc(history, func(s Snapshot) bool { return s.Date == today })
|
||||
kept = append(kept, current)
|
||||
|
||||
return kept, deltas, writeSnapshots(path, kept)
|
||||
if err := writeSnapshots(path, kept); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return kept, deltas7, deltas30, nil
|
||||
}
|
||||
|
||||
func readSnapshots(path string) ([]Snapshot, error) {
|
||||
@@ -92,18 +102,58 @@ func readSnapshots(path string) ([]Snapshot, error) {
|
||||
// applyMigrations rewrites any deprecated history keys to their current
|
||||
// canonical form. Old keys are removed; new keys accumulate stars additively
|
||||
// (in practice the old key had no concurrent new entry, so max is the same).
|
||||
//
|
||||
// Each old key is resolved transitively via resolveCanonicalKey, so a chain
|
||||
// of renames (A→B, B→C) always lands on the terminal key C regardless of the
|
||||
// order canonicalKeyMigrations happens to be iterated in (map order is
|
||||
// randomized by Go at runtime).
|
||||
func applyMigrations(stars map[string]int) map[string]int {
|
||||
for old, canonical := range canonicalKeyMigrations {
|
||||
if v, ok := stars[old]; ok {
|
||||
if stars[canonical] < v {
|
||||
stars[canonical] = v
|
||||
}
|
||||
delete(stars, old)
|
||||
for old := range canonicalKeyMigrations {
|
||||
v, ok := stars[old]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
canonical := resolveCanonicalKey(old)
|
||||
if canonical == old {
|
||||
continue // cycle or hop-cap hit; resolveCanonicalKey already logged
|
||||
}
|
||||
if stars[canonical] < v {
|
||||
stars[canonical] = v
|
||||
}
|
||||
delete(stars, old)
|
||||
}
|
||||
return stars
|
||||
}
|
||||
|
||||
// maxMigrationHops caps chain resolution so a misconfigured cycle in
|
||||
// canonicalKeyMigrations can't hang the updater.
|
||||
const maxMigrationHops = 10
|
||||
|
||||
// resolveCanonicalKey follows canonicalKeyMigrations transitively from key
|
||||
// until it reaches a terminal key (one with no further migration entry).
|
||||
// If following the chain would exceed maxMigrationHops, or a cycle is
|
||||
// detected, the cycle/misconfiguration is logged to stderr and the original
|
||||
// key is returned unchanged — this degrades to "no migration applied"
|
||||
// rather than dropping data or looping forever.
|
||||
func resolveCanonicalKey(key string) string {
|
||||
seen := map[string]bool{key: true}
|
||||
current := key
|
||||
for hop := 0; hop < maxMigrationHops; hop++ {
|
||||
next, ok := canonicalKeyMigrations[current]
|
||||
if !ok {
|
||||
return current
|
||||
}
|
||||
if seen[next] {
|
||||
fmt.Fprintf(os.Stderr, "canonicalKeyMigrations: cycle detected resolving %q (hit %q again); keeping original key unchanged\n", key, next)
|
||||
return key
|
||||
}
|
||||
seen[next] = true
|
||||
current = next
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "canonicalKeyMigrations: exceeded %d hops resolving %q; keeping original key unchanged\n", maxMigrationHops, key)
|
||||
return key
|
||||
}
|
||||
|
||||
// writeSnapshots writes to a temp file then renames atomically so a crash
|
||||
// mid-write never leaves history.jsonl truncated or partially written.
|
||||
func writeSnapshots(path string, snapshots []Snapshot) error {
|
||||
@@ -139,16 +189,24 @@ func writeSnapshots(path string, snapshots []Snapshot) error {
|
||||
}
|
||||
|
||||
// computeDeltas returns stars-now minus stars-at-or-before-cutoff for each
|
||||
// repo. Cutoff = 7 days ago UTC. The chosen prior snapshot must be within a
|
||||
// 3-day window of the cutoff; if cron was skipped for more than 10 days the
|
||||
// delta would be misleadingly labeled "Δ7d", so we return no delta instead.
|
||||
// repo, cutoff = 7 days ago UTC, with a 3-day slack window. See
|
||||
// computeDeltaOver for the general form (used for the 30-day/"momentum"
|
||||
// delta too).
|
||||
func computeDeltas(history []Snapshot, current Snapshot) map[string]int {
|
||||
return computeDeltaOver(history, current, 7, 3)
|
||||
}
|
||||
|
||||
// computeDeltaOver returns stars-now minus stars-at-or-before-cutoff for
|
||||
// each repo, where cutoff = days ago (UTC). The chosen prior snapshot must
|
||||
// fall within a slackDays window of the cutoff — i.e. in
|
||||
// (cutoff - slackDays, cutoff]; if cron was skipped for longer than that,
|
||||
// the delta would be misleadingly labeled "Δ<days>d", so we return no delta
|
||||
// for that repo instead.
|
||||
func computeDeltaOver(history []Snapshot, current Snapshot, days, slackDays int) map[string]int {
|
||||
deltas := map[string]int{}
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.AddDate(0, 0, -7).Format("2006-01-02")
|
||||
// Accept snapshots in (cutoff - 3 days, cutoff]. A snapshot older than
|
||||
// cutoff-3d is too stale to label as a 7-day delta.
|
||||
lowerBound := now.AddDate(0, 0, -10).Format("2006-01-02")
|
||||
now := timeNow().UTC()
|
||||
cutoff := now.AddDate(0, 0, -days).Format("2006-01-02")
|
||||
lowerBound := now.AddDate(0, 0, -(days + slackDays)).Format("2006-01-02")
|
||||
|
||||
var base *Snapshot
|
||||
for i := range history {
|
||||
|
||||
+167
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -175,6 +176,172 @@ func TestApplyMigrations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMigrations_ChainResolvesFullyRegardlessOfOrder(t *testing.T) {
|
||||
// A→B→C chain must always collapse to C, whether the map happens to be
|
||||
// iterated A-then-B or B-then-A (Go randomizes map iteration order).
|
||||
origMigrations := canonicalKeyMigrations
|
||||
defer func() { canonicalKeyMigrations = origMigrations }()
|
||||
canonicalKeyMigrations = map[string]string{
|
||||
"org/a": "org/b",
|
||||
"org/b": "org/c",
|
||||
}
|
||||
|
||||
// Run many times: map iteration order is randomized per run, so this
|
||||
// gives confidence the result doesn't depend on it.
|
||||
for i := 0; i < 50; i++ {
|
||||
stars := map[string]int{"org/a": 10, "other/repo": 5}
|
||||
result := applyMigrations(stars)
|
||||
|
||||
if _, ok := result["org/a"]; ok {
|
||||
t.Fatalf("iteration %d: old key 'org/a' should be removed", i)
|
||||
}
|
||||
if _, ok := result["org/b"]; ok {
|
||||
t.Fatalf("iteration %d: dead intermediate key 'org/b' should not exist", i)
|
||||
}
|
||||
if v, ok := result["org/c"]; !ok || v != 10 {
|
||||
t.Fatalf("iteration %d: terminal key 'org/c': expected 10, got %v (ok=%v)", i, v, ok)
|
||||
}
|
||||
if v, ok := result["other/repo"]; !ok || v != 5 {
|
||||
t.Fatalf("iteration %d: unrelated key 'other/repo' should be unchanged", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCanonicalKey_CycleDoesNotHang(t *testing.T) {
|
||||
origMigrations := canonicalKeyMigrations
|
||||
defer func() { canonicalKeyMigrations = origMigrations }()
|
||||
canonicalKeyMigrations = map[string]string{
|
||||
"org/a": "org/b",
|
||||
"org/b": "org/a",
|
||||
}
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() { done <- resolveCanonicalKey("org/a") }()
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
if got != "org/a" {
|
||||
t.Errorf("expected cycle to resolve back to original key 'org/a', got %q", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("resolveCanonicalKey hung on a migration cycle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCanonicalKey_ExceedsHopCapKeepsOriginal(t *testing.T) {
|
||||
origMigrations := canonicalKeyMigrations
|
||||
defer func() { canonicalKeyMigrations = origMigrations }()
|
||||
// A chain longer than maxMigrationHops with no cycle — must still
|
||||
// terminate and fall back to the original key.
|
||||
migrations := make(map[string]string, maxMigrationHops+5)
|
||||
for i := 0; i < maxMigrationHops+5; i++ {
|
||||
migrations[fmt.Sprintf("org/k%d", i)] = fmt.Sprintf("org/k%d", i+1)
|
||||
}
|
||||
canonicalKeyMigrations = migrations
|
||||
|
||||
got := resolveCanonicalKey("org/k0")
|
||||
if got != "org/k0" {
|
||||
t.Errorf("expected hop-cap fallback to original key 'org/k0', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalKeyMigrations_NoChains(t *testing.T) {
|
||||
// Invariant: every migration should point directly at the terminal
|
||||
// canonical key. If a future edit introduces A→B, B→C without collapsing
|
||||
// it to A→C, this test fails — guarding against the exact bug the
|
||||
// transitive resolver in resolveCanonicalKey defends against at runtime.
|
||||
for old, canonical := range canonicalKeyMigrations {
|
||||
if _, ok := canonicalKeyMigrations[canonical]; ok {
|
||||
t.Errorf("canonicalKeyMigrations[%q] = %q, but %q is itself a migration key — collapse the chain to point directly at the terminal canonical key", old, canonical, canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDeltaOver_FixedNow_BoundaryEdges(t *testing.T) {
|
||||
// Exercise the (cutoff-slackDays, cutoff] window with a fixed "now" so
|
||||
// the exact day-count boundaries (7d cutoff, 10d lower bound for the
|
||||
// 7-day delta) are deterministic rather than relative-to-test-run-time.
|
||||
fixed := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
orig := timeNow
|
||||
timeNow = func() time.Time { return fixed }
|
||||
defer func() { timeNow = orig }()
|
||||
|
||||
mk := func(daysAgo int) Snapshot {
|
||||
return Snapshot{
|
||||
Date: fixed.AddDate(0, 0, -daysAgo).Format("2006-01-02"),
|
||||
Stars: map[string]int{"org/repo": 50},
|
||||
}
|
||||
}
|
||||
current := Snapshot{Date: fixed.Format("2006-01-02"), Stars: map[string]int{"org/repo": 200}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
daysAgo int
|
||||
expectDelta bool
|
||||
}{
|
||||
{"exactly at 7d cutoff — included", 7, true},
|
||||
{"9d ago — inside (cutoff-3d, cutoff] window", 9, true},
|
||||
{"exactly at 10d lower bound — excluded (window is exclusive lower bound)", 10, false},
|
||||
{"11d ago — excluded", 11, false},
|
||||
{"6d ago — newer than cutoff, excluded", 6, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
history := []Snapshot{mk(tt.daysAgo)}
|
||||
deltas := computeDeltas(history, current)
|
||||
_, ok := deltas["org/repo"]
|
||||
if ok != tt.expectDelta {
|
||||
t.Errorf("daysAgo=%d: expected delta present=%v, got %v", tt.daysAgo, tt.expectDelta, ok)
|
||||
}
|
||||
if ok && deltas["org/repo"] != 150 {
|
||||
t.Errorf("daysAgo=%d: expected delta=150, got %d", tt.daysAgo, deltas["org/repo"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDeltaOver_30Day_FixedNow(t *testing.T) {
|
||||
// Same boundary shape as the 7-day delta, but for the 30d/5d-slack
|
||||
// "momentum" window used for Delta30d.
|
||||
fixed := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)
|
||||
orig := timeNow
|
||||
timeNow = func() time.Time { return fixed }
|
||||
defer func() { timeNow = orig }()
|
||||
|
||||
mk := func(daysAgo, stars int) Snapshot {
|
||||
return Snapshot{
|
||||
Date: fixed.AddDate(0, 0, -daysAgo).Format("2006-01-02"),
|
||||
Stars: map[string]int{"org/repo": stars},
|
||||
}
|
||||
}
|
||||
current := Snapshot{Date: fixed.Format("2006-01-02"), Stars: map[string]int{"org/repo": 1000}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
daysAgo int
|
||||
expectDelta bool
|
||||
}{
|
||||
{"exactly 30d cutoff — included", 30, true},
|
||||
{"33d ago — inside (cutoff-5d, cutoff] window", 33, true},
|
||||
{"exactly 35d lower bound — excluded", 35, false},
|
||||
{"36d ago — excluded", 36, false},
|
||||
{"29d ago — newer than cutoff, excluded", 29, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
history := []Snapshot{mk(tt.daysAgo, 400)}
|
||||
deltas := computeDeltaOver(history, current, 30, 5)
|
||||
_, ok := deltas["org/repo"]
|
||||
if ok != tt.expectDelta {
|
||||
t.Errorf("daysAgo=%d: expected delta present=%v, got %v", tt.daysAgo, tt.expectDelta, ok)
|
||||
}
|
||||
if ok && deltas["org/repo"] != 600 {
|
||||
t.Errorf("daysAgo=%d: expected delta=600, got %d", tt.daysAgo, deltas["org/repo"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSnapshots_AtomicWrite(t *testing.T) {
|
||||
// Verify that writeSnapshots uses atomic rename (writes to .tmp first).
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
check := flag.Bool("check", false, "validate data/agents.yml offline (no network, no token) and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *check {
|
||||
if err := runCheck("data/agents.yml"); err != nil {
|
||||
log.Printf("check failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := run(); err != nil {
|
||||
log.Fatalf("update failed: %v", err)
|
||||
}
|
||||
@@ -31,16 +43,16 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshots, deltas, err := appendHistory("data/history.jsonl", stats)
|
||||
snapshots, deltas7, deltas30, err := appendHistory("data/history.jsonl", stats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := renderReadme("templates/readme.tmpl", "README.md", stats, deltas); err != nil {
|
||||
if err := renderReadme("templates/readme.tmpl", "README.md", stats, deltas7); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeSiteData("site/data.json", stats, deltas, snapshots); err != nil {
|
||||
if err := writeSiteData("site/data.json", stats, deltas7, deltas30, snapshots); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
@@ -22,8 +21,19 @@ type Row struct {
|
||||
Category string
|
||||
}
|
||||
|
||||
// TopMover is the biggest 7-day gainer, surfaced as a one-line callout above
|
||||
// the README table. HasMover is false when no repo has a 7-day delta yet
|
||||
// (e.g. history younger than 7 days) — the template omits the line then.
|
||||
type TopMover struct {
|
||||
NameWithOwner string
|
||||
URL string
|
||||
Delta int
|
||||
HasMover bool
|
||||
}
|
||||
|
||||
func renderReadme(tmplPath, outPath string, stats []Stat, deltas map[string]int) error {
|
||||
rows := make([]Row, len(stats))
|
||||
var topMover TopMover
|
||||
for i, s := range stats {
|
||||
// Deltas are keyed by CanonicalKey (owner/repo from agents.yml).
|
||||
delta, has := deltas[s.CanonicalKey]
|
||||
@@ -39,6 +49,9 @@ func renderReadme(tmplPath, outPath string, stats []Stat, deltas map[string]int)
|
||||
Description: sanitizeCell(s.Description),
|
||||
Category: s.Category,
|
||||
}
|
||||
if has && (!topMover.HasMover || delta > topMover.Delta) {
|
||||
topMover = TopMover{NameWithOwner: s.NameWithOwner, URL: s.URL, Delta: delta, HasMover: true}
|
||||
}
|
||||
}
|
||||
|
||||
funcs := template.FuncMap{
|
||||
@@ -78,8 +91,9 @@ func renderReadme(tmplPath, outPath string, stats []Stat, deltas map[string]int)
|
||||
|
||||
execErr := tmpl.ExecuteTemplate(f, filepath.Base(tmplPath), map[string]any{
|
||||
"Rows": rows,
|
||||
"UpdatedAt": time.Now().UTC().Format("2006-01-02 15:04 UTC"),
|
||||
"UpdatedAt": timeNow().UTC().Format("2006-01-02 15:04 UTC"),
|
||||
"Total": len(rows),
|
||||
"TopMover": topMover,
|
||||
})
|
||||
// A failed close on a write path can hide lost data — surface it.
|
||||
if closeErr := f.Close(); closeErr != nil && execErr == nil {
|
||||
@@ -88,9 +102,20 @@ func renderReadme(tmplPath, outPath string, stats []Stat, deltas map[string]int)
|
||||
return execErr
|
||||
}
|
||||
|
||||
// sanitizeCell escapes pipe and newline characters so descriptions stay in one table cell.
|
||||
// sanitizeCell makes a third-party repo description safe to embed in a
|
||||
// single Markdown table cell. Order matters:
|
||||
// 1. backslash first, so escaping added by later steps isn't re-escaped
|
||||
// (also fixes `\|` rendering as a literal backslash + unescaped pipe,
|
||||
// which broke the table row).
|
||||
// 2. pipe, so the cell can't inject a table column boundary.
|
||||
// 3. angle brackets → HTML entities, so raw HTML/script tags in a
|
||||
// description can't be injected into the rendered README.
|
||||
// 4. newlines/carriage returns → space, so the cell stays one line.
|
||||
func sanitizeCell(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "|", "\\|")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
return strings.TrimSpace(s)
|
||||
|
||||
@@ -83,6 +83,30 @@ func TestSanitizeCell(t *testing.T) {
|
||||
expected: "line1 line2 line3",
|
||||
desc: "both \\n and \\r converted",
|
||||
},
|
||||
{
|
||||
name: "backslash then pipe",
|
||||
input: `\|`,
|
||||
expected: `\\\|`,
|
||||
desc: "backslash escaped first so it can't neutralize the pipe escape (finding: literal backslash + unescaped pipe previously broke the table row)",
|
||||
},
|
||||
{
|
||||
name: "lone backslash",
|
||||
input: `a\b`,
|
||||
expected: `a\\b`,
|
||||
desc: "backslash doubled so Markdown doesn't interpret it as an escape",
|
||||
},
|
||||
{
|
||||
name: "raw html img tag",
|
||||
input: "<img src=x onerror=alert(1)>",
|
||||
expected: "<img src=x onerror=alert(1)>",
|
||||
desc: "angle brackets entity-escaped so raw HTML can't be injected from a third-party description",
|
||||
},
|
||||
{
|
||||
name: "raw html script tag",
|
||||
input: "<script>alert(1)</script>",
|
||||
expected: "<script>alert(1)</script>",
|
||||
desc: "script tags neutralized via entity escaping",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// siteRow is one ranked repo in site/data.json, consumed by site/index.html.
|
||||
@@ -14,11 +13,14 @@ type siteRow struct {
|
||||
Stars int `json:"stars"`
|
||||
Delta7d int `json:"delta7d"`
|
||||
HasDelta bool `json:"hasDelta"`
|
||||
Delta30d int `json:"delta30d"`
|
||||
HasDelta30 bool `json:"hasDelta30"`
|
||||
Language string `json:"language"`
|
||||
PushedAt string `json:"pushedAt"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
type siteData struct {
|
||||
@@ -30,27 +32,31 @@ type siteData struct {
|
||||
// writeSiteData emits the JSON payload for the GitHub Pages dashboard.
|
||||
// The file is generated fresh on every updater run and is not committed;
|
||||
// the Pages deploy step in the workflow picks it up from the working tree.
|
||||
func writeSiteData(path string, stats []Stat, deltas map[string]int, history []Snapshot) error {
|
||||
func writeSiteData(path string, stats []Stat, deltas7, deltas30 map[string]int, history []Snapshot) error {
|
||||
rows := make([]siteRow, len(stats))
|
||||
for i, s := range stats {
|
||||
delta, has := deltas[s.CanonicalKey]
|
||||
delta7, has7 := deltas7[s.CanonicalKey]
|
||||
delta30, has30 := deltas30[s.CanonicalKey]
|
||||
rows[i] = siteRow{
|
||||
Key: s.CanonicalKey,
|
||||
NameWithOwner: s.NameWithOwner,
|
||||
URL: s.URL,
|
||||
Stars: s.Stars,
|
||||
Delta7d: delta,
|
||||
HasDelta: has,
|
||||
Delta7d: delta7,
|
||||
HasDelta: has7,
|
||||
Delta30d: delta30,
|
||||
HasDelta30: has30,
|
||||
Language: s.Language,
|
||||
PushedAt: s.PushedAt.Format("2006-01-02"),
|
||||
Description: s.Description,
|
||||
Category: s.Category,
|
||||
Notes: s.Notes,
|
||||
Archived: s.IsArchived,
|
||||
}
|
||||
}
|
||||
|
||||
out, err := json.Marshal(siteData{
|
||||
UpdatedAt: time.Now().UTC().Format("2006-01-02 15:04 UTC"),
|
||||
UpdatedAt: timeNow().UTC().Format("2006-01-02 15:04 UTC"),
|
||||
Rows: rows,
|
||||
History: history,
|
||||
})
|
||||
|
||||
+12
-9
@@ -97,7 +97,7 @@
|
||||
overflow-x: auto; border: 1px solid var(--border); border-radius: 12px;
|
||||
background: var(--surface); box-shadow: var(--shadow);
|
||||
}
|
||||
table { border-collapse: collapse; width: 100%; min-width: 780px; }
|
||||
table { border-collapse: collapse; width: 100%; min-width: 840px; }
|
||||
th, td { padding: .55rem .75rem; text-align: left; border-top: 1px solid var(--border); }
|
||||
thead th {
|
||||
border-top: none; background: var(--surface-2); user-select: none; white-space: nowrap;
|
||||
@@ -202,6 +202,7 @@
|
||||
<th scope="col" data-k="nameWithOwner" tabindex="0">Repo</th>
|
||||
<th scope="col" data-k="stars" class="num" tabindex="0">Stars</th>
|
||||
<th scope="col" data-k="delta7d" class="num" tabindex="0">Δ7d</th>
|
||||
<th scope="col" data-k="delta30d" class="num" tabindex="0">Δ30d</th>
|
||||
<th scope="col" data-k="category" tabindex="0">Category</th>
|
||||
<th scope="col" data-k="language" tabindex="0">Language</th>
|
||||
<th scope="col" data-k="pushedAt" tabindex="0">Last push</th>
|
||||
@@ -316,11 +317,12 @@
|
||||
|
||||
function renderTable() {
|
||||
const list = [...visible()].sort((a, b) => {
|
||||
if (sortKey === 'delta7d') {
|
||||
// repos without a 7-day baseline always sort last, either direction
|
||||
if (a.hasDelta !== b.hasDelta) return a.hasDelta ? -1 : 1;
|
||||
if (!a.hasDelta) return 0;
|
||||
return sortAsc ? a.delta7d - b.delta7d : b.delta7d - a.delta7d;
|
||||
if (sortKey === 'delta7d' || sortKey === 'delta30d') {
|
||||
// repos without a baseline for this window always sort last, either direction
|
||||
const hasKey = sortKey === 'delta7d' ? 'hasDelta' : 'hasDelta30';
|
||||
if (a[hasKey] !== b[hasKey]) return a[hasKey] ? -1 : 1;
|
||||
if (!a[hasKey]) return 0;
|
||||
return sortAsc ? a[sortKey] - b[sortKey] : b[sortKey] - a[sortKey];
|
||||
}
|
||||
const va = a[sortKey], vb = b[sortKey];
|
||||
const cmp = typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb));
|
||||
@@ -339,18 +341,19 @@
|
||||
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const tbody = document.querySelector('#tbl tbody');
|
||||
if (!list.length) {
|
||||
tbody.innerHTML = '<tr><td class="empty" colspan="8">No repos match your filters.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td class="empty" colspan="9">No repos match your filters.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = list.map(r => `
|
||||
<tr${isStale(r) ? ' class="stale"' : ''}>
|
||||
<tr${(isStale(r) || r.archived) ? ' class="stale"' : ''}>
|
||||
<td class="num rankcell">${r.rank}</td>
|
||||
<td><a class="repo" href="${esc(r.url)}">${esc(r.nameWithOwner)}</a></td>
|
||||
<td class="num">${fmtStars(r.stars)}</td>
|
||||
<td class="num ${r.hasDelta && r.delta7d > 0 ? 'pos' : r.hasDelta && r.delta7d < 0 ? 'neg' : ''}">${r.hasDelta ? (r.delta7d > 0 ? '+' : '') + r.delta7d : '—'}</td>
|
||||
<td class="num ${r.hasDelta30 && r.delta30d > 0 ? 'pos' : r.hasDelta30 && r.delta30d < 0 ? 'neg' : ''}">${r.hasDelta30 ? (r.delta30d > 0 ? '+' : '') + r.delta30d : '—'}</td>
|
||||
<td><span class="cat${catClass(r.category)}">${esc(r.category || '—')}</span></td>
|
||||
<td class="lang">${esc(r.language || '')}</td>
|
||||
<td>${esc(r.pushedAt)}${isStale(r) ? ' <span class="stalebadge">stale</span>' : ''}</td>
|
||||
<td>${esc(r.pushedAt)}${isStale(r) ? ' <span class="stalebadge">stale</span>' : ''}${r.archived ? ' <span class="stalebadge">archived</span>' : ''}</td>
|
||||
<td class="desc">${esc(r.description)}${r.notes ? ' · <em>' + esc(r.notes) + '</em>' : ''}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWriteSiteData_JSONShapeAndDeltaFields(t *testing.T) {
|
||||
fixed := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
orig := timeNow
|
||||
timeNow = func() time.Time { return fixed }
|
||||
defer func() { timeNow = orig }()
|
||||
|
||||
stats := []Stat{
|
||||
{
|
||||
CanonicalKey: "org/repo1",
|
||||
Owner: "org",
|
||||
Repo: "repo1",
|
||||
Category: "cli",
|
||||
Notes: "some note",
|
||||
Description: "a repo",
|
||||
Stars: 100,
|
||||
Language: "Go",
|
||||
PushedAt: fixed,
|
||||
URL: "https://github.com/org/repo1",
|
||||
NameWithOwner: "org/repo1",
|
||||
IsArchived: false,
|
||||
},
|
||||
{
|
||||
CanonicalKey: "org/repo2",
|
||||
Owner: "org",
|
||||
Repo: "repo2",
|
||||
Category: "web",
|
||||
Description: "another repo",
|
||||
Stars: 50,
|
||||
Language: "TypeScript",
|
||||
PushedAt: fixed,
|
||||
URL: "https://github.com/org/repo2",
|
||||
NameWithOwner: "org/repo2",
|
||||
IsArchived: true,
|
||||
},
|
||||
}
|
||||
deltas7 := map[string]int{"org/repo1": 10}
|
||||
deltas30 := map[string]int{"org/repo1": 40}
|
||||
history := []Snapshot{
|
||||
{Date: "2026-08-01", Stars: map[string]int{"org/repo1": 90, "org/repo2": 45}},
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := tmpDir + "/data.json"
|
||||
|
||||
if err := writeSiteData(tmpFile, stats, deltas7, deltas30, history); err != nil {
|
||||
t.Fatalf("writeSiteData: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
var got siteData
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal output: %v", err)
|
||||
}
|
||||
|
||||
if got.UpdatedAt != "2026-08-09 12:00 UTC" {
|
||||
t.Errorf("UpdatedAt: expected fixed timeNow value, got %q", got.UpdatedAt)
|
||||
}
|
||||
if len(got.Rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(got.Rows))
|
||||
}
|
||||
if len(got.History) != 1 {
|
||||
t.Fatalf("expected history to pass through unchanged, got %d snapshots", len(got.History))
|
||||
}
|
||||
|
||||
row1 := got.Rows[0]
|
||||
if row1.Key != "org/repo1" || row1.NameWithOwner != "org/repo1" {
|
||||
t.Errorf("row1 identity mismatch: %+v", row1)
|
||||
}
|
||||
if !row1.HasDelta || row1.Delta7d != 10 {
|
||||
t.Errorf("row1 delta7d: expected hasDelta=true delta7d=10, got hasDelta=%v delta7d=%d", row1.HasDelta, row1.Delta7d)
|
||||
}
|
||||
if !row1.HasDelta30 || row1.Delta30d != 40 {
|
||||
t.Errorf("row1 delta30d: expected hasDelta30=true delta30d=40, got hasDelta30=%v delta30d=%d", row1.HasDelta30, row1.Delta30d)
|
||||
}
|
||||
if row1.Archived {
|
||||
t.Error("row1: expected archived=false")
|
||||
}
|
||||
|
||||
row2 := got.Rows[1]
|
||||
if row2.HasDelta || row2.Delta7d != 0 {
|
||||
t.Errorf("row2 delta7d: expected no baseline (hasDelta=false, delta7d=0), got hasDelta=%v delta7d=%d", row2.HasDelta, row2.Delta7d)
|
||||
}
|
||||
if row2.HasDelta30 {
|
||||
t.Error("row2: expected no 30d baseline")
|
||||
}
|
||||
if !row2.Archived {
|
||||
t.Error("row2: expected archived=true")
|
||||
}
|
||||
|
||||
// Field-name contract with site/index.html: verify the raw JSON keys,
|
||||
// since a struct-tag typo wouldn't be caught by round-tripping through
|
||||
// the same Go struct above.
|
||||
var rawMap map[string]any
|
||||
if err := json.Unmarshal(raw, &rawMap); err != nil {
|
||||
t.Fatalf("unmarshal raw map: %v", err)
|
||||
}
|
||||
rows, ok := rawMap["rows"].([]any)
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected rows array in raw JSON, got %v", rawMap["rows"])
|
||||
}
|
||||
firstRow, ok := rows[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected row to be an object, got %T", rows[0])
|
||||
}
|
||||
for _, key := range []string{"key", "nameWithOwner", "url", "stars", "delta7d", "hasDelta", "delta30d", "hasDelta30", "language", "pushedAt", "description", "category", "archived"} {
|
||||
if _, ok := firstRow[key]; !ok {
|
||||
t.Errorf("expected JSON field %q in row, got keys: %v", key, mapKeys(firstRow))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
📊 **[Interactive dashboard with star-history charts →](https://tiennm99.github.io/awesome-coding-agents/)**
|
||||
|
||||
**Last updated:** {{.UpdatedAt}} · **Tracked:** {{.Total}} repos
|
||||
{{- if .TopMover.HasMover }}
|
||||
**Top 7-day mover:** [{{ .TopMover.NameWithOwner }}]({{ .TopMover.URL }}) ({{ formatDelta .TopMover.Delta true }} stars)
|
||||
{{- end }}
|
||||
|
||||
| # | Repo | Stars | Δ7d | Language | Last push | Description |
|
||||
|---|------|------:|----:|----------|-----------|-------------|
|
||||
@@ -39,6 +42,8 @@ agents:
|
||||
|
||||
Open a PR. The next daily run picks it up automatically.
|
||||
|
||||
PRs are validated automatically by CI (`go run . -check`): owner/repo must be non-empty and look like a real GitHub slug, category must be one of the values above, and duplicates (case-insensitive) are rejected.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ownerPattern approximates GitHub's username/org rules: alphanumeric runs
|
||||
// separated by single hyphens — equivalent to
|
||||
// "^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9]))*$" (no leading/trailing
|
||||
// hyphen, no consecutive hyphens) but written without lookahead, which Go's
|
||||
// RE2-based regexp engine doesn't support.
|
||||
var ownerPattern = regexp.MustCompile(`^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$`)
|
||||
|
||||
// repoPattern approximates GitHub's repo name rules: alphanumeric, dot,
|
||||
// underscore, hyphen.
|
||||
var repoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
// validCategories mirrors the category enum documented in
|
||||
// templates/readme.tmpl's Contributing section.
|
||||
var validCategories = map[string]bool{
|
||||
"cli": true,
|
||||
"ide": true,
|
||||
"extension": true,
|
||||
"library": true,
|
||||
"research": true,
|
||||
"web": true,
|
||||
}
|
||||
|
||||
// validateAgents checks data/agents.yml entries offline (no network, no
|
||||
// token) and returns every violation found — not just the first — so a
|
||||
// contributor sees the complete list of fixes needed in one pass.
|
||||
func validateAgents(agents []Agent) []string {
|
||||
var violations []string
|
||||
seen := make(map[string]int, len(agents)) // lowercase "owner/repo" -> first index seen
|
||||
|
||||
for i, a := range agents {
|
||||
ref := fmt.Sprintf("entry %d (owner=%q repo=%q)", i, a.Owner, a.Repo)
|
||||
|
||||
switch {
|
||||
case strings.TrimSpace(a.Owner) == "":
|
||||
violations = append(violations, fmt.Sprintf("%s: owner is empty", ref))
|
||||
case !ownerPattern.MatchString(a.Owner):
|
||||
violations = append(violations, fmt.Sprintf("%s: owner %q does not look like a valid GitHub username/org (alphanumeric, single hyphens, no leading/trailing hyphen)", ref, a.Owner))
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.TrimSpace(a.Repo) == "":
|
||||
violations = append(violations, fmt.Sprintf("%s: repo is empty", ref))
|
||||
case !repoPattern.MatchString(a.Repo):
|
||||
violations = append(violations, fmt.Sprintf("%s: repo %q contains characters not allowed in a GitHub repo name (allowed: letters, digits, '.', '_', '-')", ref, a.Repo))
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.TrimSpace(a.Category) == "":
|
||||
violations = append(violations, fmt.Sprintf("%s: category is required (one of: cli, ide, extension, library, research, web)", ref))
|
||||
case !validCategories[a.Category]:
|
||||
violations = append(violations, fmt.Sprintf("%s: category %q is not one of cli, ide, extension, library, research, web", ref, a.Category))
|
||||
}
|
||||
|
||||
key := strings.ToLower(a.Owner + "/" + a.Repo)
|
||||
if first, dup := seen[key]; dup {
|
||||
violations = append(violations, fmt.Sprintf("%s: duplicate of entry %d (case-insensitive owner/repo match)", ref, first))
|
||||
} else {
|
||||
seen[key] = i
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
// runCheck loads data/agents.yml offline and validates it, printing every
|
||||
// violation to stderr and returning a non-nil error if any are found. It
|
||||
// never makes a network call, so it's safe to run against fork PRs without
|
||||
// a GitHub token.
|
||||
func runCheck(path string) error {
|
||||
agents, err := loadAgents(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load %s: %w", path, err)
|
||||
}
|
||||
|
||||
violations := validateAgents(agents)
|
||||
if len(violations) > 0 {
|
||||
for _, v := range violations {
|
||||
fmt.Fprintln(os.Stderr, v)
|
||||
}
|
||||
return fmt.Errorf("%d violation(s) found in %s", len(violations), path)
|
||||
}
|
||||
|
||||
fmt.Printf("%s: %d agents valid\n", path, len(agents))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateAgents_ValidEntriesNoViolations(t *testing.T) {
|
||||
agents := []Agent{
|
||||
{Owner: "aider-ai", Repo: "aider", Category: "cli"},
|
||||
{Owner: "cline", Repo: "cline", Category: "extension"},
|
||||
{Owner: "a", Repo: "b.c-d_e", Category: "web"},
|
||||
}
|
||||
violations := validateAgents(agents)
|
||||
if len(violations) != 0 {
|
||||
t.Errorf("expected no violations, got %v", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgents_EmptyOwner(t *testing.T) {
|
||||
violations := validateAgents([]Agent{{Owner: "", Repo: "repo1", Category: "cli"}})
|
||||
if !anyContains(violations, "owner is empty") {
|
||||
t.Errorf("expected 'owner is empty' violation, got %v", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgents_InvalidOwnerChars(t *testing.T) {
|
||||
tests := []string{"foo_bar", "-leading", "trailing-", "double--hyphen", "foo owner"}
|
||||
for _, owner := range tests {
|
||||
t.Run(owner, func(t *testing.T) {
|
||||
violations := validateAgents([]Agent{{Owner: owner, Repo: "repo1", Category: "cli"}})
|
||||
if !anyContains(violations, "does not look like a valid GitHub username/org") {
|
||||
t.Errorf("owner %q: expected invalid-owner violation, got %v", owner, violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgents_EmptyRepo(t *testing.T) {
|
||||
violations := validateAgents([]Agent{{Owner: "org", Repo: "", Category: "cli"}})
|
||||
if !anyContains(violations, "repo is empty") {
|
||||
t.Errorf("expected 'repo is empty' violation, got %v", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgents_InvalidRepoChars(t *testing.T) {
|
||||
tests := []string{"foo/bar", "foo bar", "foo@bar", "foo#bar"}
|
||||
for _, repo := range tests {
|
||||
t.Run(repo, func(t *testing.T) {
|
||||
violations := validateAgents([]Agent{{Owner: "org", Repo: repo, Category: "cli"}})
|
||||
if !anyContains(violations, "characters not allowed in a GitHub repo name") {
|
||||
t.Errorf("repo %q: expected invalid-repo violation, got %v", repo, violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgents_MissingCategory(t *testing.T) {
|
||||
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Category: ""}})
|
||||
if !anyContains(violations, "category is required") {
|
||||
t.Errorf("expected 'category is required' violation, got %v", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgents_InvalidCategory(t *testing.T) {
|
||||
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Category: "framework"}})
|
||||
if !anyContains(violations, "is not one of cli, ide, extension, library, research, web") {
|
||||
t.Errorf("expected invalid-category violation, got %v", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgents_AllValidCategoriesAccepted(t *testing.T) {
|
||||
for _, cat := range []string{"cli", "ide", "extension", "library", "research", "web"} {
|
||||
t.Run(cat, func(t *testing.T) {
|
||||
violations := validateAgents([]Agent{{Owner: "org", Repo: "repo1", Category: cat}})
|
||||
if len(violations) != 0 {
|
||||
t.Errorf("category %q: expected no violations, got %v", cat, violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgents_DuplicateCaseInsensitive(t *testing.T) {
|
||||
agents := []Agent{
|
||||
{Owner: "Foo", Repo: "Bar", Category: "cli"},
|
||||
{Owner: "foo", Repo: "bar", Category: "cli"},
|
||||
}
|
||||
violations := validateAgents(agents)
|
||||
if !anyContains(violations, "duplicate of entry 0") {
|
||||
t.Errorf("expected duplicate violation referencing entry 0, got %v", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgents_NoDuplicateForDistinctRepos(t *testing.T) {
|
||||
agents := []Agent{
|
||||
{Owner: "foo", Repo: "bar", Category: "cli"},
|
||||
{Owner: "foo", Repo: "baz", Category: "cli"},
|
||||
}
|
||||
violations := validateAgents(agents)
|
||||
if len(violations) != 0 {
|
||||
t.Errorf("expected no violations for distinct repos, got %v", violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgents_CollectsAllViolationsNotJustFirst(t *testing.T) {
|
||||
agents := []Agent{
|
||||
{Owner: "", Repo: "", Category: ""},
|
||||
{Owner: "org", Repo: "repo1", Category: "not-a-category"},
|
||||
}
|
||||
violations := validateAgents(agents)
|
||||
// entry 0: owner empty, repo empty, category empty = 3 violations.
|
||||
// entry 1: invalid category = 1 violation.
|
||||
if len(violations) != 4 {
|
||||
t.Errorf("expected 4 violations collected across both entries, got %d: %v", len(violations), violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheck_CurrentAgentsYML(t *testing.T) {
|
||||
// The real data/agents.yml must always pass -check; this is the
|
||||
// regression guard for that invariant.
|
||||
if err := runCheck("data/agents.yml"); err != nil {
|
||||
t.Errorf("expected data/agents.yml to pass validation, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheck_BadYAMLReportsViolationsAndError(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := tmpDir + "/bad-agents.yml"
|
||||
// loadAgents itself already rejects empty owner/repo, so this fixture
|
||||
// covers the violation classes only validateAgents catches: bad owner
|
||||
// chars, bad repo chars, invalid category, and a case-insensitive dup.
|
||||
content := `agents:
|
||||
- owner: "bad owner"
|
||||
repo: "valid-repo"
|
||||
category: cli
|
||||
- owner: "org"
|
||||
repo: "bad/repo"
|
||||
category: not-a-real-category
|
||||
- owner: "Dup"
|
||||
repo: "Repo"
|
||||
category: web
|
||||
- owner: "dup"
|
||||
repo: "repo"
|
||||
category: web
|
||||
`
|
||||
if err := os.WriteFile(tmpFile, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
|
||||
err := runCheck(tmpFile)
|
||||
if err == nil {
|
||||
t.Fatal("expected runCheck to fail on invalid agents.yml, got nil error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "violation(s) found") {
|
||||
t.Errorf("expected error to summarize violation count, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCheck_MissingFile(t *testing.T) {
|
||||
err := runCheck("/nonexistent/path/agents.yml")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func anyContains(list []string, substr string) bool {
|
||||
for _, s := range list {
|
||||
if strings.Contains(s, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user