diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e21b9ef..90bf906 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -35,11 +35,13 @@ Optional fields: `notes` (for clarifications or caveats) ## Handling Duplicates and Changes -**Duplicate repos:** If a repo appears twice (same owner/repo), the second entry is ignored in the next daily run. +**Duplicate repos:** CI rejects them. `go run . -check` fails on a case-insensitive `owner/repo` match, so a duplicate never reaches a daily run. -**Renamed repos:** If a GitHub repo is renamed after being tracked, update the `repo` field in `data/agents.yml`. The updater will fetch fresh metadata under the new name. Historical data in `data/history.jsonl` remains under the old key and is not migrated. +**Renamed repos:** GitHub redirects the old slug, so the updater keeps working — it prints a `::warning::` naming the new slug. Update `owner`/`repo` in `data/agents.yml` to the new slug and add the old key to `canonicalKeyMigrations` in `history.go`, or the repo's star history detaches and its deltas show `—`. -**Deprecation:** To remove an agent, delete its entry from `data/agents.yml`. The next run will drop it from the README; historical data is preserved. +**Deprecation:** To remove an agent, delete its entry from `data/agents.yml`. The next run drops it from the README; its history stays in `data/history.jsonl`, so re-adding the entry later restores its star chart. + +**Staleness:** An entry with no push in **6 months** is dropped. Past **3 months** the updater prints a `::warning::` naming the repo and its days idle, so the daily run surfaces candidates without anyone auditing the list by hand. Removal stays a human decision: a repo can go quiet between releases, and a historically significant one (`gpt-engineer`) is kept with a `notes` marker instead. ## PR Review diff --git a/github.go b/github.go index 19bd00d..1e634d6 100644 --- a/github.go +++ b/github.go @@ -68,6 +68,12 @@ var httpClient = &http.Client{Timeout: 30 * time.Second} // 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 + // chunkSize is the max aliases per GraphQL request (GitHub node-limit safety margin). const chunkSize = 50 @@ -111,14 +117,20 @@ func fetchStats(token string, agents []Agent) ([]Stat, error) { lang = node.PrimaryLanguage.Name } - // Drift detection: surface renames/archival as GitHub Actions run - // annotations so a human notices without polling every repo by hand. + // 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) } - if node.IsArchived { + 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) } stats = append(stats, Stat{ diff --git a/github_test.go b/github_test.go index 17f4af1..053e602 100644 --- a/github_test.go +++ b/github_test.go @@ -6,10 +6,12 @@ import ( "io" "net/http" "net/http/httptest" + "os" "regexp" "strconv" "strings" "testing" + "time" ) // aliasRe extracts the numeric alias index from a GraphQL query fragment @@ -227,3 +229,86 @@ func TestFetchStats_DriftWarnings(t *testing.T) { } } } + +// captureStdout redirects os.Stdout for the duration of fn and returns what +// was written, so the ::warning:: annotations fetchStats emits are assertable. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + orig := os.Stdout + os.Stdout = w + defer func() { os.Stdout = orig }() + + fn() + + if err := w.Close(); err != nil { + t.Fatalf("close pipe writer: %v", err) + } + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read pipe: %v", err) + } + return string(out) +} + +func TestFetchStats_StaleWarning(t *testing.T) { + fixed := time.Date(2026, 9, 11, 0, 0, 0, 0, time.UTC) + origNow := timeNow + timeNow = func() time.Time { return fixed } + defer func() { timeNow = origNow }() + + agents := []Agent{ + {Owner: "org", Repo: "fresh", Category: "cli"}, + {Owner: "org", Repo: "stale", Category: "cli"}, + {Owner: "org", Repo: "stale-and-archived", Category: "cli"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + node := func(name, pushedAt string, archived bool) map[string]any { + return map[string]any{ + "stargazerCount": 5, + "nameWithOwner": name, + "pushedAt": pushedAt, + "isArchived": archived, + } + } + out, _ := json.Marshal(map[string]any{"data": map[string]any{ + // 2 days idle: inside the window, no warning. + "r0": node("org/fresh", "2026-09-09T00:00:00Z", false), + // 120 days idle: past staleWarnAfter. + "r1": node("org/stale", "2026-05-14T00:00:00Z", false), + // Also stale, but archived — the archived warning covers it. + "r2": node("org/stale-and-archived", "2025-01-01T00:00:00Z", true), + }}) + _, _ = w.Write(out) // httptest write error is not actionable in a test fake + })) + defer srv.Close() + withGraphQLURL(t, srv) + + var stats []Stat + var fetchErr error + logs := captureStdout(t, func() { stats, fetchErr = fetchStats("test-token", agents) }) + + if fetchErr != nil { + t.Fatalf("fetchStats: %v (staleness should warn, not fail)", fetchErr) + } + if len(stats) != 3 { + t.Fatalf("expected 3 stats, got %d", len(stats)) + } + + if want := "::warning::repo org/stale has no push in 120 days"; !strings.Contains(logs, want) { + t.Errorf("missing stale warning %q in:\n%s", want, logs) + } + if strings.Contains(logs, "org/fresh has no push") { + t.Errorf("warned about a repo pushed 2 days ago:\n%s", logs) + } + if strings.Contains(logs, "org/stale-and-archived has no push") { + t.Errorf("stale warning duplicates the archived warning:\n%s", logs) + } + if want := "::warning::repo org/stale-and-archived is archived"; !strings.Contains(logs, want) { + t.Errorf("missing archived warning %q in:\n%s", want, logs) + } +} diff --git a/templates/readme.tmpl b/templates/readme.tmpl index d13b5b6..0aeb9c6 100644 --- a/templates/readme.tmpl +++ b/templates/readme.tmpl @@ -35,7 +35,9 @@ 1. **An AI coding agent or assistant itself** — a tool that autonomously writes, edits, or reviews code. Frameworks, wrappers, observability layers, and workspaces *for* agents are out of scope. 2. **Notable**: roughly **10,000+ GitHub stars** (the current list floor). -3. **Open source and actively maintained** (archived repos are kept only for historical significance, marked as such). +3. **Open source and actively maintained** — no push in **6 months** means the entry is dropped. The daily run flags anything past **3 months** for review, so the ranking reflects tools people can actually use today. + +Archived or abandoned repos are kept only when they are historically significant, and are marked as such in [`data/agents.yml`](data/agents.yml). Add an agent to [`data/agents.yml`](data/agents.yml):