diff --git a/agents.go b/agents.go index cebbbae..f3c0665 100644 --- a/agents.go +++ b/agents.go @@ -13,6 +13,11 @@ type Agent struct { Tags []string `yaml:"tags"` Notes string `yaml:"notes,omitempty"` + // Description is a curated one-liner that replaces the repo's own GitHub + // description in the README and dashboard. Many upstream descriptions are + // empty, vague, or marketing copy that says nothing about what the tool is. + Description string `yaml:"description,omitempty"` + // Category is the retired single-select field that tags replaced. It is // still parsed so a stale entry fails validation with a message naming // the replacement, rather than being silently ignored. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index c71a0b6..71b0180 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -8,12 +8,13 @@ Edit [`data/agents.yml`](../data/agents.yml) and add an entry: agents: - owner: github-username-or-org repo: repository-name + description: Terminal coding agent that edits files and runs commands in your repo tags: [terminal, byo-model, interactive, community] ``` Required fields: `owner`, `repo`, `tags` -Optional fields: `notes` (for clarifications or caveats) +Optional fields: `description` (curated one-liner), `notes` (for clarifications or caveats) ## Field Reference @@ -22,8 +23,18 @@ Optional fields: `notes` (for clarifications or caveats) | `owner` | string | Yes | GitHub user or organization that owns the repo | | `repo` | string | Yes | Repository name on GitHub | | `tags` | list | Yes | Tags from the vocabulary below; at least one surface tag, at most one origin tag | +| `description` | string | No | Curated one-line description shown instead of the repo's GitHub description; one line, no `\|`, at most 140 characters | | `notes` | string | No | Additional context or disclaimers | +### Writing a description + +Say what the tool is (terminal agent, VS Code extension, editor, desktop app, +self-hosted server) and the one trait that sets it apart, in one plain sentence +with no trailing period. Base it on the repo's own README, like tags. Leave out +marketing adjectives, emoji, star counts, and model version names, which go +stale within months. Without a `description`, the README shows whatever the +repo's GitHub description says, which is often empty or vague. + ## Scope This list ranks **developer tools built around AI** — things a developer uses to diff --git a/github.go b/github.go index 5f6a040..a99c009 100644 --- a/github.go +++ b/github.go @@ -140,13 +140,18 @@ func fetchStats(token string, agents []Agent) ([]Stat, error) { 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: node.Description, + Description: desc, Stars: node.StargazerCount, Language: lang, PushedAt: node.PushedAt, diff --git a/github_test.go b/github_test.go index 7287702..33e0f67 100644 --- a/github_test.go +++ b/github_test.go @@ -51,6 +51,8 @@ func TestFetchStats_ChunkingHappyPath(t *testing.T) { for i := range agents { agents[i] = Agent{Owner: "org", Repo: fmt.Sprintf("repo%02d", i), Tags: []string{"terminal", "community"}} } + // A curated description in agents.yml replaces the GitHub one. + agents[3].Description = "curated" var requestCount int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -114,6 +116,18 @@ func TestFetchStats_ChunkingHappyPath(t *testing.T) { if !found { t.Error("expected org/repo52 (second chunk) in results") } + for _, s := range stats { + switch s.NameWithOwner { + case "org/repo03": + if s.Description != "curated" { + t.Errorf("org/repo03: expected curated description, got %q", s.Description) + } + case "org/repo04": + if s.Description != "desc 4" { + t.Errorf("org/repo04: expected GitHub description, got %q", s.Description) + } + } + } } func TestFetchStats_MissingNodeNamesTheRepo(t *testing.T) { diff --git a/templates/readme.tmpl b/templates/readme.tmpl index 4809156..9bb4f73 100644 --- a/templates/readme.tmpl +++ b/templates/readme.tmpl @@ -45,6 +45,7 @@ Add an agent to [`data/agents.yml`](data/agents.yml): agents: - owner: github-username-or-org repo: repository-name + description: Terminal coding agent that edits files and runs commands in your repo tags: [terminal, byo-model, interactive, community] ``` @@ -62,7 +63,7 @@ Apply a tag only when the repo's own README, docs, or topics back it up. See [`d 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, every tag must come from the vocabulary above with at least one surface tag and at most one origin tag, and duplicates (case-insensitive) are rejected. +PRs are validated automatically by CI (`go run . -check`): owner/repo must be non-empty and look like a real GitHub slug, every tag must come from the vocabulary above with at least one surface tag and at most one origin tag, an optional `description` must be one line of at most 140 characters without `|`, and duplicates (case-insensitive) are rejected. ## License diff --git a/validate.go b/validate.go index e929386..7c44235 100644 --- a/validate.go +++ b/validate.go @@ -102,6 +102,7 @@ func validateAgents(agents []Agent) []string { violations = append(violations, fmt.Sprintf("%s: category %q is no longer a field — replace it with tags, e.g. tags: [terminal, byo-model, interactive, community]", ref, a.Category)) } violations = append(violations, validateTags(ref, a.Tags)...) + violations = append(violations, validateDescription(ref, a.Description)...) key := strings.ToLower(a.Owner + "/" + a.Repo) if first, dup := seen[key]; dup { @@ -166,3 +167,25 @@ func validateTags(ref string, tags []string) []string { return violations } + +// maxDescriptionLen keeps a curated description to one readable table line. +const maxDescriptionLen = 140 + +// validateDescription checks the optional curated description: one line, +// bounded length, and no pipe, which would split the README table cell. +func validateDescription(ref, desc string) []string { + var violations []string + if desc == "" { + return nil + } + if strings.TrimSpace(desc) != desc { + violations = append(violations, fmt.Sprintf("%s: description has leading or trailing whitespace", ref)) + } + if strings.ContainsAny(desc, "|\n\r") { + violations = append(violations, fmt.Sprintf("%s: description must be one line without '|'", ref)) + } + if n := len([]rune(desc)); n > maxDescriptionLen { + violations = append(violations, fmt.Sprintf("%s: description is %d characters, max %d", ref, n, maxDescriptionLen)) + } + return violations +} diff --git a/validate_test.go b/validate_test.go index 950af01..b4e88ef 100644 --- a/validate_test.go +++ b/validate_test.go @@ -204,3 +204,29 @@ func anyContains(list []string, substr string) bool { } return false } + +func TestValidateAgents_Description(t *testing.T) { + tests := []struct { + name, desc, want string + }{ + {"valid", "Terminal coding agent with multi-provider support", ""}, + {"pipe", "CLI | agent", "without '|'"}, + {"newline", "line one\nline two", "without '|'"}, + {"padded", " padded", "leading or trailing whitespace"}, + {"too long", strings.Repeat("x", maxDescriptionLen+1), "max 140"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + violations := validateAgents([]Agent{{Owner: "o", Repo: "r", Tags: []string{"terminal"}, Description: tt.desc}}) + if tt.want == "" { + if len(violations) != 0 { + t.Errorf("expected no violations, got %v", violations) + } + return + } + if !anyContains(violations, tt.want) { + t.Errorf("expected violation containing %q, got %v", tt.want, violations) + } + }) + } +}