From 04e83e91bcf8825cccaed37974a6ac0c78f131cd Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 27 Jul 2026 20:03:23 -0700 Subject: [PATCH 1/2] feat(brand): plan repo metadata changes, dry-run by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repo description, homepage and topics are the one marketing surface with no file behind them: git does not version them, markers cannot reach them, and they are what shows on the repo card, in GitHub search and in the org listing. So they drift silently and stay drifted — seven descriptions still advertise 41+ or 55+ models against a catalog of 66. This proposes the changes and writes nothing without --apply. Rollback stays restore-repo-metadata.mjs against the before-snapshot. The design constraint is that it NEVER writes prose it invented. A description is only ever edited by substituting a stale number for the published one; the sentence stays as whoever wrote it left it. That keeps the mechanical part mechanical and leaves authorship where it belongs. Topics are proposed rather than derived-and-trusted, and reading the first dry run is what caught the flaw in deriving them from repos.json's "shape": shape says how a repo ships, not what it is about. renovate-config is `md` for the same reason the x402 docs repos are, and would have been tagged x402/usdc. Overridden by name, and topics are additive so a human's existing choices are never removed. Homepage is only filled where repos.json states one. 33 repos have none and repos.json has no data for them; guessing a URL is not a gap this can close. --- brand/plan-repo-metadata.mjs | 138 +++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 brand/plan-repo-metadata.mjs diff --git a/brand/plan-repo-metadata.mjs b/brand/plan-repo-metadata.mjs new file mode 100644 index 0000000..7af1de7 --- /dev/null +++ b/brand/plan-repo-metadata.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** + * Propose GitHub repo description / homepage / topics changes. + * + * Repo metadata is the one marketing surface with no file behind it: git does + * not version it, markers cannot reach it, and it is what shows on the repo + * card, in GitHub search and in the org listing. It drifts silently and stays + * drifted, which is why 7 descriptions still advertised 41+ or 55+ models + * against a catalog of 66. + * + * node brand/plan-repo-metadata.mjs print the plan, touch nothing + * node brand/plan-repo-metadata.mjs --apply write it + * node brand/plan-repo-metadata.mjs --only numbers restrict to number fixes + * + * Rollback is restore-repo-metadata.mjs, against repo-metadata.before.json. + * + * DESIGN: this NEVER writes prose it invented. A description is only ever + * edited by substituting a stale number for the published one — the sentence + * stays as whoever wrote it left it. Topics and homepage are proposed, because + * those are structured discoverability data rather than claims, and a proposal + * you can read beats a blank field on 29 of 37 repos. + */ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ORG = "BlockRunAI"; +const argv = process.argv.slice(2); +const apply = argv.includes("--apply"); +const only = argv.includes("--only") ? argv[argv.indexOf("--only") + 1] : null; + +const gh = (args) => execFileSync("gh", args, { encoding: "utf8" }); +const N = JSON.parse(readFileSync(join(HERE, "..", "brand-numbers.json"), "utf8")); + +/** + * A number in a description that must equal a published value. + * + * Deliberately narrow: only the claim vocabulary this artifact owns. "3 tools" + * in a repo that ships three tools is not ours to touch, so nothing here + * matches a bare count without one of these words after it. + */ +const NUMBER_RULES = [ + [/(? [r.name.toLowerCase(), r])); + +const live = JSON.parse( + gh([ + "repo", "list", ORG, "--limit", "200", "--json", + "name,description,repositoryTopics,homepageUrl,visibility,isArchived", + ]), +).filter((r) => r.visibility === "PUBLIC" && !r.isArchived); + +const topicsOf = (r) => + new Set((r.repositoryTopics ?? []).map((t) => (typeof t === "string" ? t : t.name))); + +let numberFixes = 0, topicAdds = 0, homepageAdds = 0, clean = 0; + +for (const repo of live.sort((a, b) => a.name.localeCompare(b.name))) { + const spec = byName.get(repo.name.toLowerCase()); + const changes = []; + const args = ["repo", "edit", `${ORG}/${repo.name}`]; + + // ── 1. Numbers. Substitution only — the prose is left as written. ──────── + let desc = repo.description ?? ""; + const before = desc; + for (const [re, value, word] of NUMBER_RULES) { + desc = desc.replace(re, (m) => { + const qualifier = m.match(/(AI |chat |LLM )/)?.[0] ?? ""; + return `${value} ${qualifier}${word}`; + }); + } + if (desc !== before) { + changes.push({ kind: "numbers", text: `description: ${JSON.stringify(before)}\n -> ${JSON.stringify(desc)}` }); + args.push("--description", desc); + numberFixes++; + } + + if (only !== "numbers") { + // ── 2. Topics — additive only. Never remove what a human chose. ──────── + const want = TOPIC_OVERRIDES[repo.name] ?? TOPICS_BY_SHAPE[spec?.shape] ?? []; + const now = topicsOf(repo); + const add = want.filter((t) => !now.has(t)); + if (add.length && now.size === 0) { + changes.push({ kind: "topics", text: `topics: +${add.join(", ")}` }); + for (const t of add) args.push("--add-topic", t); + topicAdds++; + } + + // ── 3. Homepage — only where repos.json states one. Never guessed. ───── + if (spec?.homepageUrl && !repo.homepageUrl) { + changes.push({ kind: "homepage", text: `homepage: -> ${spec.homepageUrl}` }); + args.push("--homepage", spec.homepageUrl); + homepageAdds++; + } + } + + if (!changes.length) { clean++; continue; } + console.log(`\n${repo.name}${spec ? "" : " (not in repos.json)"}`); + for (const c of changes) console.log(` ${c.text}`); + if (apply) { gh(args); console.log(" applied"); } +} + +console.log( + `\n${apply ? "applied" : "would change"}: ${numberFixes} description(s), ` + + `${topicAdds} topic set(s), ${homepageAdds} homepage(s) | already clean: ${clean}`, +); +if (!apply) console.log("\nnothing was written. re-run with --apply to write."); From 5740e29ee468cd76a2b3ce49cccdab3c3eca7189 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 27 Jul 2026 20:08:15 -0700 Subject: [PATCH 2/2] feat(brand): derive homepage from registries a repo demonstrably publishes to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass filled homepage only where repos.json stated one, which was nowhere — 33 repos had none and repos.json had no data for them. But for a package repo the homepage is a fact, not a judgement call: take the name from the repo's own manifest, ask npm or PyPI whether that package exists, and use the package page if it does. A repo that publishes nothing still gets nothing. That is the line between deriving and inventing. 12 more repos now get one, each verified against the registry before it is proposed. --- brand/plan-repo-metadata.mjs | 58 ++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/brand/plan-repo-metadata.mjs b/brand/plan-repo-metadata.mjs index 7af1de7..adea5d1 100644 --- a/brand/plan-repo-metadata.mjs +++ b/brand/plan-repo-metadata.mjs @@ -71,6 +71,50 @@ const TOPICS_BY_SHAPE = { archive: [], }; +/** Fetch a file from a repo's default branch, or null. */ +function fileFrom(repo, path) { + try { + return gh([ + "api", `repos/${ORG}/${repo}/contents/${path}`, + "-H", "Accept: application/vnd.github.raw", + ]); + } catch { + return null; + } +} + +/** + * A homepage derived from a registry the repo actually publishes to. + * + * NOT a guess: the package name comes from the repo's own manifest, and the + * registry is asked whether that package exists before the URL is proposed. + * A repo that publishes nothing gets nothing — 33 repos have no homepage and + * inventing one for them is not a gap a script should close. + */ +function derivedHomepage(repo, shape) { + if (shape === "ts-npm") { + const pkg = fileFrom(repo.name, "package.json"); + if (!pkg) return null; + let name; + try { ({ name } = JSON.parse(pkg)); } catch { return null; } + if (!name || JSON.parse(pkg).private) return null; + try { + gh(["api", "--silent", `https://registry.npmjs.org/${encodeURIComponent(name)}`]); + return `https://www.npmjs.com/package/${name}`; + } catch { return null; } + } + if (shape === "py-pypi") { + const toml = fileFrom(repo.name, "pyproject.toml"); + const name = toml?.match(/^\s*name\s*=\s*["']([^"']+)["']/m)?.[1]; + if (!name) return null; + try { + gh(["api", "--silent", `https://pypi.org/pypi/${name}/json`]); + return `https://pypi.org/project/${name}/`; + } catch { return null; } + } + return null; +} + const repos = JSON.parse(readFileSync(join(HERE, "..", "..", "brand", "repos.json"), "utf8")).repos; const byName = new Map(repos.map((r) => [r.name.toLowerCase(), r])); @@ -117,11 +161,15 @@ for (const repo of live.sort((a, b) => a.name.localeCompare(b.name))) { topicAdds++; } - // ── 3. Homepage — only where repos.json states one. Never guessed. ───── - if (spec?.homepageUrl && !repo.homepageUrl) { - changes.push({ kind: "homepage", text: `homepage: -> ${spec.homepageUrl}` }); - args.push("--homepage", spec.homepageUrl); - homepageAdds++; + // ── 3. Homepage — stated in repos.json, else derived from a registry the + // repo demonstrably publishes to. Never invented. + if (!repo.homepageUrl) { + const url = spec?.homepageUrl ?? derivedHomepage(repo, spec?.shape); + if (url) { + changes.push({ kind: "homepage", text: `homepage: -> ${url}` }); + args.push("--homepage", url); + homepageAdds++; + } } }