diff --git a/.github/repository-topics.json b/.github/repository-topics.json new file mode 100644 index 0000000..3cb2043 --- /dev/null +++ b/.github/repository-topics.json @@ -0,0 +1,130 @@ +{ + "description": "Discovery metadata for GitHub repository topics and bot-readable repository classification.", + + "repository": "hyperpolymath/hotchocolabot", + "rationale": "docs/REPO_METADATA.adoc", + "issue": "https://github.com/hyperpolymath/hotchocolabot/issues/12", + + "description_text": "Educational robotics platform: an intentionally over-engineered hot chocolate dispenser, in Rust for Raspberry Pi, that teaches reverse engineering, systems thinking and safety-critical design to students aged 12–18 — with a mock-hardware HAL, a Certified Null Operations (CNO) safety state machine, and a full workshop curriculum.", + "short_description": "Educational robotics: a deliberately over-engineered hot chocolate dispenser for teaching reverse engineering and systems thinking.", + + "minimum_topic_count": 7, + "recommended_topic_count": 20, + "description_max_chars": 350, + + "topics": [ + "epistemic-computing", + "epistemic-infrastructure", + "equivalence-aware-computing", + "hyperpolymath", + "typed-provenance", + "veridical-computing", + "open-source", + "robotics", + "education", + "teaching", + "reverse-engineering", + "stem-education", + "rust", + "raspberry-pi", + "embedded", + "mechatronics", + "state-machine", + "safety-critical", + "formal-verification", + "certified-null-operations" + ], + + "topic_groups": { + "estate_spine": [ + "epistemic-computing", + "epistemic-infrastructure", + "equivalence-aware-computing", + "hyperpolymath", + "typed-provenance", + "veridical-computing" + ], + "estate_convention": ["open-source"], + "project": [ + "robotics", + "education", + "teaching", + "reverse-engineering", + "stem-education", + "mechatronics" + ], + "platform": ["rust", "raspberry-pi", "embedded", "state-machine"], + "method": [ + "safety-critical", + "formal-verification", + "certified-null-operations" + ] + }, + + "selection_rationale": { + "bot_discovery": [ + "robotics", + "education", + "reverse-engineering", + "mechatronics", + "stem-education", + "safety-critical", + "state-machine", + "certified-null-operations", + "formal-verification", + "embedded" + ], + "human_discovery": [ + "robotics", + "education", + "teaching", + "reverse-engineering", + "stem-education", + "rust", + "raspberry-pi", + "embedded" + ], + "domain_accuracy": [ + "mechatronics", + "state-machine", + "safety-critical", + "formal-verification", + "certified-null-operations" + ] + }, + + "related_repositories": [ + { + "repo": "hyperpolymath/absolute-zero", + "relation": "CNO is formalised there; this repo is the taught, physical instantiation" + }, + { + "repo": "hyperpolymath/robot-vacuum-cleaner", + "relation": "Sibling robotics build in the same Rust toolchain" + }, + { + "repo": "hyperpolymath/lustreiser", + "relation": "Sibling safety-critical embedded work sharing the state-machine and formal-methods vocabulary" + }, + { + "repo": "hyperpolymath/JuliaKids.jl", + "relation": "Sibling CS-education-for-children package" + }, + { + "repo": "hyperpolymath/pseudoscript", + "relation": "Sibling pedagogy project (pseudocode as a first language)" + }, + { + "repo": "hyperpolymath/gitbot-fleet", + "relation": "Repository quality enforcement fleet; also the historical mirror path for this project" + }, + { + "repo": "hyperpolymath/methodologies", + "relation": "Where the captured design decisions behind this build live" + }, + { + "repo": "hyperpolymath/palimpsest-license", + "relation": "Licence referenced by this repository" + } + ] +} diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index c47ceb2..a091dc3 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 == [Unreleased] +=== Added +- **Repository metadata** (closes #12): the GitHub description and topic set are + now declared in `.github/repository-topics.json` and explained in + `docs/REPO_METADATA.adoc`, with `scripts/repo-metadata.sh` and three `just` + recipes to show, audit and apply them. Fills in the empty repository + description, adds the 14 domain topics the repository was missing, and adds the + `open-source` convention tag carried by 338 of the estate's ~343 repositories. +- **Related projects table** in `README.adoc`, linking this repository to the + sibling work it shares vocabulary and audiences with (`absolute-zero`, + `lustreiser`, `robot-vacuum-cleaner`, `JuliaKids.jl`, `pseudoscript`, + `methodologies`, `gitbot-fleet`). + === Planned - Hardware assembly and testing - Workshop pilot delivery (3 sessions, 15+ students) diff --git a/Justfile b/Justfile index 128acfb..e28a5df 100644 --- a/Justfile +++ b/Justfile @@ -337,4 +337,28 @@ help: @echo " just audit - Security audit" @echo " just rsr-check - Check RSR compliance" @echo "" + @echo "Repository metadata (GitHub description + topics):" + @echo " just repo-metadata - Show the declared description/topics" + @echo " just repo-metadata-audit - Check GitHub against the declaration" + @echo " just repo-metadata-apply - Push them to GitHub (needs admin)" + @echo "" @echo "For full list: just --list" + +# === Repository Metadata Recipes === +# The GitHub "About" box (description + topics) lives in repository settings, not +# in the tree, so it cannot be reviewed in a diff and cannot be enforced by CI. +# These recipes make .github/repository-topics.json the reviewable source of truth. +# Requires: jq, gh. Rationale for every value: docs/REPO_METADATA.adoc + +# Show the canonical description and topics declared for this repository +repo-metadata: + @scripts/repo-metadata.sh show + +# Compare GitHub's live settings against the declared metadata (read-only; non-zero on drift) +# Pass REPO= to audit a sibling repository in the estate instead +repo-metadata-audit REPO="hyperpolymath/hotchocolabot": + @scripts/repo-metadata.sh audit "{{REPO}}" + +# Push the declared description and topics to GitHub (needs admin on the repository) +repo-metadata-apply REPO="hyperpolymath/hotchocolabot": + @scripts/repo-metadata.sh apply "{{REPO}}" diff --git a/README.adoc b/README.adoc index d90d16f..0ef4e3e 100644 --- a/README.adoc +++ b/README.adoc @@ -18,6 +18,19 @@ Canonical home for HotChocolaBot is this repo: `https://github.com/hyperpolymath The old `gitbot-fleet` nested path is a mirror, not the source of truth. ==== +[NOTE] +==== +Discovery tags for bots and humans: `epistemic-computing`, `epistemic-infrastructure`, +`equivalence-aware-computing`, `hyperpolymath`, `typed-provenance`, `veridical-computing`, +`open-source`, `robotics`, `education`, `teaching`, `reverse-engineering`, `stem-education`, +`rust`, `raspberry-pi`, `embedded`, `mechatronics`, `state-machine`, `safety-critical`, +`formal-verification`, `certified-null-operations`. + +The machine-readable source for these recommended GitHub topics is +link:.github/repository-topics.json[`.github/repository-topics.json`]. +Rationale for each one: link:docs/REPO_METADATA.adoc[`docs/REPO_METADATA.adoc`]. +==== + == Overview HotChocolaBot is an over-engineered hot chocolate dispenser designed to teach reverse engineering, systems thinking, and problem-solving skills through heutagogic (self-directed) learning. Students deconstruct and analyze the system to understand complex engineering principles in an engaging, hands-on environment. @@ -273,6 +286,21 @@ HotChocolaBot demonstrates concepts from ongoing research: - *UPM (Universal Project Manager)*: Case study for project management theory - *Formal Verification*: State machine approach bridges theory to practice +=== Related Projects + +Where the ideas above are taken further, in this and neighbouring repositories: + +| Repository | Relationship | +|-----------|--------------| +| link:https://github.com/hyperpolymath/absolute-zero[absolute-zero] | CNO formalised across Coq, Lean 4 and Agda. This project is the taught, physical instantiation | +| link:https://github.com/hyperpolymath/lustreiser[lustreiser] | Sibling safety-critical embedded work; shares the state-machine and formal-methods vocabulary | +| link:https://github.com/hyperpolymath/robot-vacuum-cleaner[robot-vacuum-cleaner] | Sibling robotics build in the same Rust toolchain | +| link:https://github.com/hyperpolymath/JuliaKids.jl[JuliaKids.jl] | Sibling CS-education-for-children package | +| link:https://github.com/hyperpolymath/pseudoscript[pseudoscript] | Sibling pedagogy project — pseudocode as a first language | +| link:https://github.com/hyperpolymath/methodologies[methodologies] | Where the captured design decisions behind this build live | +| link:https://github.com/hyperpolymath/gitbot-fleet[gitbot-fleet] | Repository quality enforcement fleet; also the historical mirror path for this project | +| link:https://github.com/hyperpolymath/palimpsest-license[palimpsest-license] | The licence referenced by this repository | + == Competition Submission === Target: Robotics for Good Youth Challenge 2025-2026 @@ -337,6 +365,24 @@ image:https://img.shields.io/badge/License-MPL--2.0-blue.svg[License: MPL-2.0,li cargo bench ``` +== Repository Metadata + +This repository's GitHub description and topics — the text GitHub indexes that +lives outside the tree — are declared in `.github/repository-topics.json` and +explained in link:docs/REPO_METADATA.adoc[docs/REPO_METADATA.adoc]. Change the +JSON, not GitHub's settings, so the change is reviewable in a diff: + +```bash += Show the declared description and topics +just repo-metadata + += Check GitHub against the declaration (read-only) +just repo-metadata-audit + += Push them to GitHub (needs admin on the repository) +just repo-metadata-apply +``` + == License Code is under MPL-2.0 and docs are under CC-BY-SA-4.0. diff --git a/docs/REPO_METADATA.adoc b/docs/REPO_METADATA.adoc new file mode 100644 index 0000000..91d89b8 --- /dev/null +++ b/docs/REPO_METADATA.adoc @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Repository Metadata: description and topics +:toc: + +== Why this document exists + +GitHub's *About* section (description + topics) is the only text GitHub indexes +that is not inside the repository. It is what a stranger sees in search results, +in `github.com/hyperpolymath/hotchocolabot/topics` listings, and in the +organization's own topic browse pages. Until now this repository had a full +README and *no* description and *no* domain topics, which made it effectively +invisible outside the estate while still being one of only a handful of its +~343 repositories without the `open-source` convention tag. + +Issue https://github.com/hyperpolymath/hotchocolabot/issues/12 records the +missing description. The description and topic set below close it. + +The canonical values live in `.github/repository-topics.json` — *this document +explains them, the JSON is what tools read.* Do not edit the values in two +places. + +=== Why that filename + +`.github/repository-topics.json` is not an invention here. `gitbot-fleet` +already ships a file of that name described as "Discovery metadata for GitHub +repository topics and bot-readable repository classification", and its README +points at it as "the machine-readable source for these recommended GitHub +topics". This file follows that schema — `description`, `short_description`, +`minimum_topic_count`, `recommended_topic_count`, `topics`, +`selection_rationale` — so anything in the estate that learns to read one reads +all of them. The GitHub description itself is carried in `description_text`, +because the estate schema already spends the `description` key on explaining the +file. + +That schema is also the reason the README carries a `[NOTE]` block listing the +twenty topics in prose, next to a link to the JSON: a human who lands in a clone +should see the same discovery surface as a bot. + +== The description + +[source] +---- +Educational robotics platform: an intentionally over-engineered hot chocolate +dispenser, in Rust for Raspberry Pi, that teaches reverse engineering, systems +thinking and safety-critical design to students aged 12–18 — with a +mock-hardware HAL, a Certified Null Operations (CNO) safety state machine, and a +full workshop curriculum. +---- + +331 characters (GitHub's limit is 350; the headroom is deliberate, because the +value contains an en dash and an em dash and nobody should have to measure +whether a platform counts those as one character or three). + +It is built from six claims, each of which is backed by something in the tree: + +. *Educational robotics platform* — the category, and the phrase already used in + `Cargo.toml` and the README tagline. Leads with the category so a topic or + keyword search lands on the right noun before any detail. +. *Intentionally over-engineered* — the project's single most distinctive + property, and the reason it exists. "Intentionally" prevents a reader from + mistaking the complexity for neglect, which is the usual first impression of an + over-built repo. +. *Hot chocolate dispenser … in Rust for Raspberry Pi* — what it physically is, + plus the two facts a developer filters on. Named technologies do more for + search recall than adjectives. +. *Teaches reverse engineering, systems thinking and safety-critical design to + students aged 12–18* — the audience, the method and the age band. The age band + is a real search term for the teachers, and the three subjects are the + documented learning outcomes in `education/workshops/workshop_curriculum.adoc`. +. *Mock-hardware HAL* — the feature that makes the project usable without buying + a Raspberry Pi. It is the reason a developer who cannot solder still bookmarks + it. +. *CNO safety state machine* — the bridge to the rest of the estate. + `absolute-zero` and `maa-framework` already carry the term "Certified Null + Operations" in their descriptions and topics; repeating it here makes this + repo discoverable from those, and makes those discoverable from this. + +=== What the description deliberately does not say + +The estate's issue templates include a 🪞 *Affirmation / claim drift* form for "a +public claim overstates what the code does". The description respects that: + +* It does **not** say "formally verified". `src/safety/mod.rs` builds a + complete `smlang` model of the safety states, but the shipped demo flow drives + safety through explicit checks rather than that model. The description claims a + "CNO safety state machine", which is what the code is. +* It does **not** say "works" or "open hardware". Hardware assembly is still + unchecked in the README development status, and the project carries no + Open Source Hardware certification. + +If a future version earns the stronger claim, update the JSON and this section +together. + +== The topics + +Twenty topics — GitHub's hard maximum, and the estate schema's +`recommended_topic_count`. Every slot is spent deliberately, and each one is a +real, defensible claim about the repository. The groups below are also encoded +in the JSON under `topic_groups`, and `scripts/repo-metadata.sh` refuses to run +if those groups and the flat `topics` list ever disagree. + +=== Estate spine (6) — non-negotiable, cross-repository navigation + +[cols="1,1,3"] +|=== +| Topic | Estate repos | Why it stays + +| `epistemic-computing` | 342 | Core taxonomy term. +| `epistemic-infrastructure` | 342 | Core taxonomy term. +| `equivalence-aware-computing` | 334 | Core taxonomy term. +| `typed-provenance` | 334 | Core taxonomy term. +| `veridical-computing` | 342 | Core taxonomy term. +| `hyperpolymath` | 343 | The org slug itself. +|=== + +These six appear on effectively every repository in the estate. They are the +joins of the cross-repository graph: a reader who lands here from any of them +can pivot to any other. Dropping one would make this repo a dead end in the +estate's own browse pages — so they are kept even though none of them describes +a hot chocolate machine. + +=== Estate convention (1) — the gap this PR closes + +`open-source` is on 338 of 343 estate repositories. This one was missing it, +which meant it was invisible from `topic:open-source` — by far the largest +single browse page the project belongs on. + +=== What the project is (6) + +[cols="1,3"] +|=== +| Topic | Justification + +| `robotics` | The category in `Cargo.toml`'s own description; the thing a teacher types. +| `education` | Already the estate's tag for its teaching repositories (14 uses). +| `teaching` | Shares a topic page with the estate's other pedagogy work: `JuliaKids.jl`, `JuliaForChildren.jl`, `pseudoscript`. +| `reverse-engineering` | The primary skill taught; the project's own hook to `somethings-fishy`, the estate's other reverse-engineering repository. +| `stem-education` | How the project is actually sold and scheduled — a makerspace/STEM curriculum, not a hobby build. +| `mechatronics` | Zero uses elsewhere in the estate, and uniquely this repo's: the project belongs to UAL Creative Communities' MechCC postdisciplinary mechatronics group. High signal, zero competition for the slot. +|=== + +=== What it is built with (4) + +`rust` (96 uses across the estate, the single most common language tag), +`raspberry-pi` (the deployment target, and a topic `network-outpost` already +uses), `embedded` (the `categories` field in `Cargo.toml` is literally +`embedded`), and `state-machine` (the `smlang` safety model in +`src/safety/mod.rs`). + +=== The method (3) + +`safety-critical` (the README's safety section, and the vocabulary +`lustreiser` already uses), `formal-verification` (62 estate uses; the repo's +`RSR_COMPLIANCE.adoc` and research-connections section), and +`certified-null-operations` — the exact tag `absolute-zero` carries, which is +what makes this repo reachable from the estate's formal-verification work and +vice versa. + +=== Slots deliberately left empty + +GitHub allows twenty. The runners-up and why they lost: + +* `systems-thinking` — a headline learning outcome, and it *is* in the + description text, where GitHub's full-text index will find it. A topic slot was + worth more spent on `mechatronics` or `teaching`, which have no full-text + equivalent. +* `open-hardware` — the BOM, wiring diagram and assembly guide are open, but the + project is not Open Source Hardware certified. Claiming it would be exactly the + kind of claim drift the estate polices. +* `iot` — the temperature sensor and LCD are I2C peripherals, not a networked + thing. The project is deliberately offline-first. + +== Applying it + +The settings live in GitHub's repo configuration, not in the tree, so applying +them needs admin on the repository. From the repository root: + +[source,bash] +---- +# What the canonical metadata says +just repo-metadata + +# What GitHub currently has, next to what it should have (read-only) +just repo-metadata-audit +just repo-metadata-audit REPO=hyperpolymath/absolute-zero + +# Apply it (needs admin on the repo) +just repo-metadata-apply +just repo-metadata-apply REPO=hyperpolymath/absolute-zero +---- + +`repo-metadata-apply` is a full sync: it `PATCH`es the whole `topics` array, so +it cannot leave stale tags behind the way repeated `--add-topic` would, and it +refuses to run if the declaration is malformed. To do it by hand, without +`just`: + +[source,bash] +---- +jq '{description: .description_text, topics: .topics}' \ + .github/repository-topics.json \ + | gh api -X PATCH repos/hyperpolymath/hotchocolabot --input - +---- + +The shorter `gh repo edit … --add-topic` form is *additive* — it is fine for +adding a tag in a hurry, but it will not remove one that has been retired, and it +will not notice that the description in GitHub no longer matches this file. +Prefer the sync. + +=== Two estate rules that shaped this + +* *No Python.* The estate's governance gate runs `git ls-files '*.py'` and fails + the pull request, estate-wide, by design. So the checker here is bash + `jq`, + which are preinstalled on every runner and preinstalled here. +* *`actions.lock` is keyed by workflow path.* A workflow the lock does not list + is rejected before any step runs — `startup_failure`, therefore no check run at + all. That is why there is no workflow in this change that runs + `repo-metadata-audit` on a schedule: a new workflow file would not run. A + `just` recipe plus a documented monthly manual sweep is the honest form of + this check until the lock is regenerated. If you do add a workflow, add its + path to `.github/workflows/actions.lock` in the same commit. + +== Using this for the rest of the estate + +The same three recipes work against any estate repository, which is what makes +this a fix for the *estate's* findability rather than one repo's: + +* `repo-metadata-audit REPO=` reports any repository whose live + description or topics have drifted from its own declared metadata. Run it + across `hyperpolymath/*` to find the rest of the gaps. +* `repo-metadata-apply REPO=` pushes a declared metadata file to a sibling + repository once that repository has one. + +The estate rules worth writing down, so siblings stay mutually findable: + +. *Keep the six spine topics.* They are the only thing that makes the ~343 + repositories browseable as one body of work. +. *Keep `open-source`.* 338 of 343 have it; a repository without it drops out of + the largest topic page the estate lives on. +. *Spend the remaining thirteen slots on terms a newcomer would actually type* + (`robotics`, `raspberry-pi`, `reverse-engineering`), not on restating the + spine. +. *Reuse a sibling's exact topic spelling* when you want to share a topic page — + `certified-null-operations` is shared with `absolute-zero`; + `safety-critical` with `lustreiser`; `teaching` with `JuliaKids.jl`. Two + spellings of one idea split the page in half. +. *Put the estate term in the description too.* GitHub full-text search covers + the description, so a term that appears there is findable even when the topic + slot went to something else. +. *Name the file `.github/repository-topics.json` and keep the estate schema.* + A sibling that invents `repo-metadata.json` or drops `selection_rationale` is + invisible to whatever eventually reads these files in bulk. + +== Related repositories + +Reciprocal links matter as much as the tags: each of these is a place a reader +of this repo plausibly wants next, and each gets a link back. + +[cols="1,3"] +|=== +| Repository | Why it is related + +| link:https://github.com/hyperpolymath/absolute-zero[absolute-zero] | CNO formalised across Coq/Lean/Agda. This repo is the taught, physical instantiation. +| link:https://github.com/hyperpolymath/robot-vacuum-cleaner[robot-vacuum-cleaner] | Sibling robotics build in the same Rust toolchain. +| link:https://github.com/hyperpolymath/lustreiser[lustreiser] | Sibling safety-critical embedded work; shares the state-machine and formal-methods vocabulary. +| link:https://github.com/hyperpolymath/JuliaKids.jl[JuliaKids.jl] | Sibling CS-education-for-children package. +| link:https://github.com/hyperpolymath/pseudoscript[pseudoscript] | Sibling pedagogy project — pseudocode as a first language. +| link:https://github.com/hyperpolymath/gitbot-fleet[gitbot-fleet] | Repository quality enforcement fleet; also the historical mirror path for this project. +| link:https://github.com/hyperpolymath/methodologies[methodologies] | Where the captured design decisions behind this build live. +| link:https://github.com/hyperpolymath/palimpsest-license[palimpsest-license] | The licence referenced by this repository. +|=== + +The same list, machine-readable, is in `related_repositories` in +`.github/repository-topics.json`. + +== Known follow-ups (not fixed here) + +* **Licence detection.** GitHub reports this repository's licence as + `NOASSERTION`, because the REUSE layout (`LICENSE` plus `LICENSES/`) carries + several licences at once. That suppresses the licence badge and puts the + repository outside `license:` search filters. The code is MPL-2.0 and the docs + are CC-BY-SA-4.0; picking which one GitHub should report is a legal decision + for the maintainer, not a metadata fix, so it is flagged rather than made. +* **`docs/CITATIONS.adoc` references `../CITATION.cff` and `../codemeta.json`**, + which do not exist in this repository. `robot-vacuum-cleaner` ships both. Adding + them would make this project citable and would register it with citation + indexes — a real findability gain, and a separate change. diff --git a/justfile b/justfile index 128acfb..e28a5df 100644 --- a/justfile +++ b/justfile @@ -337,4 +337,28 @@ help: @echo " just audit - Security audit" @echo " just rsr-check - Check RSR compliance" @echo "" + @echo "Repository metadata (GitHub description + topics):" + @echo " just repo-metadata - Show the declared description/topics" + @echo " just repo-metadata-audit - Check GitHub against the declaration" + @echo " just repo-metadata-apply - Push them to GitHub (needs admin)" + @echo "" @echo "For full list: just --list" + +# === Repository Metadata Recipes === +# The GitHub "About" box (description + topics) lives in repository settings, not +# in the tree, so it cannot be reviewed in a diff and cannot be enforced by CI. +# These recipes make .github/repository-topics.json the reviewable source of truth. +# Requires: jq, gh. Rationale for every value: docs/REPO_METADATA.adoc + +# Show the canonical description and topics declared for this repository +repo-metadata: + @scripts/repo-metadata.sh show + +# Compare GitHub's live settings against the declared metadata (read-only; non-zero on drift) +# Pass REPO= to audit a sibling repository in the estate instead +repo-metadata-audit REPO="hyperpolymath/hotchocolabot": + @scripts/repo-metadata.sh audit "{{REPO}}" + +# Push the declared description and topics to GitHub (needs admin on the repository) +repo-metadata-apply REPO="hyperpolymath/hotchocolabot": + @scripts/repo-metadata.sh apply "{{REPO}}" diff --git a/scripts/repo-metadata.sh b/scripts/repo-metadata.sh new file mode 100755 index 0000000..3e243de --- /dev/null +++ b/scripts/repo-metadata.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# repo-metadata.sh — single source of truth for this repository's GitHub +# description and topics. +# +# The About section of a GitHub repository lives in repository *settings*, not in +# the tree, which is exactly why it drifts: nothing in CI can see it, and nothing +# in a clone carries it. This script closes that gap. It reads the declared +# values from .github/repository-topics.json and either reports them, compares them +# against what GitHub currently has, or pushes them to GitHub. +# +# scripts/repo-metadata.sh show [REPO] +# scripts/repo-metadata.sh audit [REPO] # read-only; exits 1 on drift +# scripts/repo-metadata.sh apply [REPO] # needs admin on REPO +# +# REPO defaults to hyperpolymath/hotchocolabot; override with $REPO or $2. +# +# Every sibling repository in hyperpolymath/* can use this unchanged: point it at +# its own .github/repository-topics.json via the META environment variable, or copy +# the file in. The rationale for every value is in docs/REPO_METADATA.adoc. +# +# Requires: bash, jq, gh (authenticated). + +set -euo pipefail + +META="${META:-.github/repository-topics.json}" +DEFAULT_REPO="${DEFAULT_REPO:-hyperpolymath/hotchocolabot}" + +die() { + echo "error: $*" >&2 + exit 1 +} + +# Codepoint length, not bytes: the description carries an en dash and an em +# dash, and GitHub counts characters. jq's `length` is the portable way to get +# that regardless of the caller's locale. +description_text() { + jq -r '.description_text' "$META" +} + +description_chars() { + jq -r '.description_text | length' "$META" +} + +usage() { + sed -n '3,23p' "$0" | sed 's/^# \{0,1\}//' +} + +# --- preflight --------------------------------------------------------------- + +need() { + command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed" +} + +check_declaration() { + [ -f "$META" ] || die "metadata file not found: $META" + jq -e . "$META" >/dev/null 2>&1 || die "metadata file is not valid JSON: $META" + + local desc topics + desc="$(description_text)" + topics="$(jq -r '(.topics // []) | length' "$META")" + + [ -n "$desc" ] || die "metadata declares an empty description" + [ "$(description_chars)" -le 350 ] || + die "description is $(description_chars) characters; GitHub's limit is 350" + + [ "$topics" -le 20 ] || die "$topics topics declared; GitHub's limit is 20" + [ "$topics" -gt 0 ] || die "no topics declared" + + local bad + bad="$(jq -r '(.topics // [])[] | select((test("^[a-z0-9-]+$") or (length > 50)) | not)' "$META")" + [ -z "$bad" ] || die "invalid topic(s) — must be lowercase alphanumerics/hyphens, max 50 chars: $bad" + + # The grouped view exists for humans; make sure it cannot drift from the flat + # list that actually gets sent to GitHub. + if jq -e 'has("topic_groups")' "$META" >/dev/null 2>&1; then + local grouped + grouped="$(jq -r '[.topic_groups[]] | flatten | unique | join(" ")' "$META")" + local flat + flat="$(jq -r '.topics | unique | join(" ")' "$META")" + [ "$grouped" = "$flat" ] || + die "topic_groups and topics disagree — they must contain the same set" + fi + + # selection_rationale is the estate's why-is-this-tagged view (bot / human / + # domain). A topic may appear in more than one bucket, but never in a bucket + # without also being a real topic. + if jq -e 'has("selection_rationale")' "$META" >/dev/null 2>&1; then + local orphans + orphans="$(jq -r ' + (.topics) as $all + | [.selection_rationale[] | .[] | select(. as $t | ($all | index($t)) == null)] + | unique | join(" ")' "$META")" + [ -z "$orphans" ] || + die "selection_rationale mentions topic(s) not in topics: $orphans" + fi +} + +# --- modes ------------------------------------------------------------------- + +mode_show() { + check_declaration + echo "=== Canonical repository metadata ($META) ===" + echo + description_text + echo + printf 'topics (%s/20):\n' "$(jq -r '.topics | length' "$META")" + jq -r '.topic_groups // {} | to_entries[] | " \(.key):\n" + (.value | map(" - " + .) | join("\n"))' "$META" + echo + echo "description chars: $(description_chars) / 350" + echo + echo "Rationale for every value: docs/REPO_METADATA.adoc" + echo "Apply with: just repo-metadata-apply" +} + +mode_audit() { + check_declaration + need gh + + local repo="$1" + local live live_topics want_description want_topics + live="$(gh api "repos/${repo}")" || + die "could not read repos/${repo} — is the repo name right and 'gh' authenticated?" + + live_topics="$(printf '%s' "$live" | jq -r '(.topics // []) | sort | join(" ")')" + want_description="$(description_text)" + want_topics="$(jq -r '.topics | sort | join(" ")' "$META")" + + local live_description + live_description="$(printf '%s' "$live" | jq -r '.description // ""')" + + echo "=== $repo ===" + echo "live description: ${live_description:-(empty)}" + echo " declared: $want_description" + if [ "$live_description" = "$want_description" ]; then + echo " status: ✓ match" + else + echo " status: ✗ DRIFT" + fi + + echo + echo "live topics: ${live_topics:-(none)}" + echo " declared: $want_topics" + if [ "$live_topics" = "$want_topics" ]; then + echo " status: ✓ match" + else + echo " status: ✗ DRIFT" + comm -23 \ + <(printf '%s\n' "$live_topics" | tr ' ' '\n' | sed '/^$/d' | sort) \ + <(printf '%s\n' "$want_topics" | tr ' ' '\n' | sed '/^$/d' | sort) | + sed 's/^/ only live: /' + comm -13 \ + <(printf '%s\n' "$live_topics" | tr ' ' '\n' | sed '/^$/d' | sort) \ + <(printf '%s\n' "$want_topics" | tr ' ' '\n' | sed '/^$/d' | sort) | + sed 's/^/ only declared: /' + fi + + if [ "$live_description" = "$want_description" ] && [ "$live_topics" = "$want_topics" ]; then + echo + echo "no drift." + return 0 + fi + + echo + echo "Fix with: just repo-metadata-apply REPO=${repo}" + return 1 +} + +mode_apply() { + check_declaration + need gh + + local repo="$1" + echo "Applying declared description + topics to ${repo}..." + + # PATCH with a topics *array* replaces the whole set, so this cannot leave + # stale topics behind the way repeated --add-topic would. + if ! jq '{description: .description_text, topics: .topics}' "$META" | + gh api -X PATCH "repos/${repo}" --input - --silent >/dev/null; then + die "GitHub refused the update. Applying repository settings needs admin on ${repo}." + fi + + echo "done. verifying..." + mode_audit "$repo" || die "update reported success but verification failed" +} + +# --- entry point ------------------------------------------------------------- + +main() { + local cmd="${1:-show}" + local repo="${2:-${REPO:-$DEFAULT_REPO}}" + + case "$cmd" in + show | audit | apply) ;; + -h | --help | help) + usage + return 0 + ;; + *) + usage >&2 + die "unknown mode '$cmd' (expected show, audit or apply)" + ;; + esac + + case "$cmd" in + show) mode_show ;; + audit) mode_audit "$repo" ;; + apply) mode_apply "$repo" ;; + esac +} + +main "$@"