From 15a471e2db0a259f5d33e3d2f28295a047ec2701 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:31:40 +1000 Subject: [PATCH 1/6] feat(tools): sync issue template tags/categories from scripts.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Category and tag lists were hardcoded in four places (validate_scripts.py, issue_to_scripts.py, and both issue form templates) and had already drifted once. scripts.schema.json is now the single canonical source: tags and categories are auto-promoted from actual usage in scripts.json (tags are a pure usage snapshot; categories are monotonic add-only since they drive the site's nav tabs and shouldn't vanish just because they're briefly empty). tools/sync_issue_template_options.py regenerates the schema's examples and both templates' marker-bounded option blocks, with --check/--write modes and tests. A new "New Category" free-text field mirrors the existing "Additional Tags" field, since the dropdown previously had no way to introduce a new value. CI (validate-issue-templates.yml) self-heals same-repo branches on push, gates PRs with --check, and — since GITHUB_TOKEN can never push to a fork — posts a sticky comment with the diff and self-resolve instructions on fork PRs via a carefully scoped pull_request_target job. Co-Authored-By: Claude Sonnet 5 --- .github/ISSUE_TEMPLATE/add-script.yml | 27 +- .github/ISSUE_TEMPLATE/update-script.yml | 25 +- .../workflows/validate-issue-templates.yml | 153 ++++++++ .vscode/settings.json | 8 + scripts.schema.json | 79 +++++ tools/issue_to_scripts.py | 40 +-- tools/sync_issue_template_options.py | 335 ++++++++++++++++++ tools/test_sync_issue_template_options.py | 220 ++++++++++++ tools/validate_scripts.py | 17 - 9 files changed, 858 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/validate-issue-templates.yml create mode 100644 .vscode/settings.json create mode 100644 scripts.schema.json create mode 100644 tools/sync_issue_template_options.py create mode 100644 tools/test_sync_issue_template_options.py diff --git a/.github/ISSUE_TEMPLATE/add-script.yml b/.github/ISSUE_TEMPLATE/add-script.yml index f0fdff8..2f2ef4a 100644 --- a/.github/ISSUE_TEMPLATE/add-script.yml +++ b/.github/ISSUE_TEMPLATE/add-script.yml @@ -25,17 +25,33 @@ body: id: category attributes: label: Category - description: Choose the most appropriate category. + description: | + Choose the most appropriate category. If none of these fit, leave this blank and + use the New Category field below instead. options: + # --- BEGIN AUTO-GENERATED CATEGORIES (see scripts.schema.json: items.properties.category.examples) --- - Audio & Media - Flight Controller Config - - Games & Fun - GPS & Mapping + - Games & Fun - Logging & Analysis - Radio Tools - Telemetry & Widgets + # --- END AUTO-GENERATED CATEGORIES --- validations: - required: true + required: false + + - type: input + id: category_extra + attributes: + label: New Category + description: | + Only fill this in if none of the categories above fit — this creates a brand-new + gallery category (a new top-level tab on the site), so please only use it if you're + confident an existing one genuinely doesn't apply. + placeholder: e.g. Simulators + validations: + required: false - type: textarea id: description @@ -72,6 +88,7 @@ body: label: Tags description: Check all that apply. options: + # --- BEGIN AUTO-GENERATED TAGS (see scripts.schema.json: items.properties.tags.items.examples) --- - label: ardupilot - label: battery - label: betaflight @@ -85,12 +102,14 @@ body: - label: gps - label: heli - label: inav - - label: logging + - label: multi-protocol - label: quad - label: rotorflight - label: script + - label: spektrum - label: tool - label: widget + # --- END AUTO-GENERATED TAGS --- - type: input id: tags_extra diff --git a/.github/ISSUE_TEMPLATE/update-script.yml b/.github/ISSUE_TEMPLATE/update-script.yml index 36c4a6f..ccf7f20 100644 --- a/.github/ISSUE_TEMPLATE/update-script.yml +++ b/.github/ISSUE_TEMPLATE/update-script.yml @@ -23,15 +23,31 @@ body: id: category attributes: label: Category - description: Leave unselected to keep the existing category. + description: | + Leave unselected to keep the existing category. If none of these fit, use the + New Category field below instead. options: + # --- BEGIN AUTO-GENERATED CATEGORIES (see scripts.schema.json: items.properties.category.examples) --- - Audio & Media - Flight Controller Config - - Games & Fun - GPS & Mapping + - Games & Fun - Logging & Analysis - Radio Tools - Telemetry & Widgets + # --- END AUTO-GENERATED CATEGORIES --- + validations: + required: false + + - type: input + id: category_extra + attributes: + label: New Category + description: | + Only fill this in if you want to change to a category not in the dropdown above — + this creates a brand-new gallery category (a new top-level tab on the site), so + please only use it if you're confident an existing one genuinely doesn't apply. + placeholder: e.g. Simulators validations: required: false @@ -72,6 +88,7 @@ body: Leave **all boxes unchecked** to keep existing tags unchanged. Checking any box (or filling in Additional Tags) will **replace the full tag list**. options: + # --- BEGIN AUTO-GENERATED TAGS (see scripts.schema.json: items.properties.tags.items.examples) --- - label: ardupilot - label: battery - label: betaflight @@ -85,12 +102,14 @@ body: - label: gps - label: heli - label: inav - - label: logging + - label: multi-protocol - label: quad - label: rotorflight - label: script + - label: spektrum - label: tool - label: widget + # --- END AUTO-GENERATED TAGS --- - type: input id: tags_extra diff --git a/.github/workflows/validate-issue-templates.yml b/.github/workflows/validate-issue-templates.yml new file mode 100644 index 0000000..503943c --- /dev/null +++ b/.github/workflows/validate-issue-templates.yml @@ -0,0 +1,153 @@ +name: Validate Issue Template Tags & Categories + +on: + push: + paths: + - 'scripts.json' + - 'scripts.schema.json' + - '.github/ISSUE_TEMPLATE/add-script.yml' + - '.github/ISSUE_TEMPLATE/update-script.yml' + - 'tools/sync_issue_template_options.py' + - 'tools/test_sync_issue_template_options.py' + - '.github/workflows/validate-issue-templates.yml' + pull_request: + paths: + - 'scripts.json' + - 'scripts.schema.json' + - '.github/ISSUE_TEMPLATE/add-script.yml' + - '.github/ISSUE_TEMPLATE/update-script.yml' + - 'tools/sync_issue_template_options.py' + - 'tools/test_sync_issue_template_options.py' + - '.github/workflows/validate-issue-templates.yml' + pull_request_target: + paths: + - 'scripts.json' + - 'scripts.schema.json' + - '.github/ISSUE_TEMPLATE/add-script.yml' + - '.github/ISSUE_TEMPLATE/update-script.yml' + - 'tools/sync_issue_template_options.py' + - 'tools/test_sync_issue_template_options.py' + - '.github/workflows/validate-issue-templates.yml' + +permissions: + contents: read + +jobs: + # Read-only check. Runs for every PR, including forks (which get a + # restricted, non-writable GITHUB_TOKEN here — that's fine, this job never + # writes anything). + check: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.1.0 + - name: Run sync script tests + run: uv run tools/test_sync_issue_template_options.py + - name: Check tags/category sync + run: uv run tools/sync_issue_template_options.py --check + + # Self-heals same-repo branches (this workflow's own commit is made with + # GITHUB_TOKEN, so it never re-triggers itself or any other workflow — + # GitHub Actions doesn't fire new workflow-triggering events for + # GITHUB_TOKEN-authored pushes). + sync: + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.1.0 + - name: Run sync script tests + run: uv run tools/test_sync_issue_template_options.py + - name: Sync tags/category + run: uv run tools/sync_issue_template_options.py --write | tee /tmp/sync-output.txt + - name: Annotate new categories + run: | + line=$(grep '^new_categories=' /tmp/sync-output.txt || true) + if [ -n "$line" ]; then + echo "::warning::New categories introduced: ${line#new_categories=} — please confirm these aren't duplicates/typos before merging." + fi + - name: Annotate unused categories + run: | + line=$(grep '^unused_categories=' /tmp/sync-output.txt || true) + if [ -n "$line" ]; then + echo "::notice::Categories with no current entries: ${line#unused_categories=} — consider pruning scripts.schema.json if any of these are stale." + fi + - name: Commit corrections if needed + run: | + if ! git diff --quiet -- scripts.schema.json .github/ISSUE_TEMPLATE/add-script.yml .github/ISSUE_TEMPLATE/update-script.yml; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add scripts.schema.json .github/ISSUE_TEMPLATE/add-script.yml .github/ISSUE_TEMPLATE/update-script.yml + git commit -m "chore: sync issue template tags/categories" + git push origin "HEAD:${{ github.ref_name }}" + fi + + # Fork PRs can never be auto-committed to (GITHUB_TOKEN cannot push to a + # different repository under any trigger, pull_request_target included — + # that event only grants a writable token for *this* repo). Instead this + # job safely computes the same fix and posts it as a sticky PR comment. + suggest-fix-for-fork-prs: + if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + # Deliberately no `ref:` override: this checks out the *base* branch's + # trusted copy of the sync script, never the PR's own version — a + # malicious PR can't smuggle in a modified script for this elevated + # job to execute. + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.1.0 + - name: Fetch PR's scripts.json as data only (never executed) + run: | + git fetch origin "pull/${{ github.event.pull_request.number }}/head" + git show FETCH_HEAD:scripts.json > /tmp/pr-scripts.json + - name: Compute what --write would change, without committing + id: diff + run: | + uv run tools/sync_issue_template_options.py --write --scripts-json /tmp/pr-scripts.json || true + if git diff --quiet -- scripts.schema.json .github/ISSUE_TEMPLATE/add-script.yml .github/ISSUE_TEMPLATE/update-script.yml; then + echo "has_diff=false" >> "$GITHUB_OUTPUT" + else + git diff -- scripts.schema.json .github/ISSUE_TEMPLATE/add-script.yml .github/ISSUE_TEMPLATE/update-script.yml | head -n 200 > /tmp/sync.diff + echo "has_diff=true" >> "$GITHUB_OUTPUT" + fi + # Discard the local write — nothing from this job is ever committed. + git checkout -- scripts.schema.json .github/ISSUE_TEMPLATE/add-script.yml .github/ISSUE_TEMPLATE/update-script.yml + - name: Post or update sticky PR comment + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const marker = ''; + const hasDiff = '${{ steps.diff.outputs.has_diff }}' === 'true'; + let body; + if (hasDiff) { + const diff = fs.existsSync('/tmp/sync.diff') ? fs.readFileSync('/tmp/sync.diff', 'utf8') : ''; + body = marker + '\n\n⚠️ This PR introduces a new tag or category not yet reflected in `scripts.schema.json` / the issue form templates.\n\n' + + 'Since this PR comes from a fork, this can\'t be fixed automatically here. To resolve it:\n' + + '- a maintainer can pull this branch into the repo (the automatic fix runs on push), or\n' + + '- you (or a maintainer) can run `uv run tools/sync_issue_template_options.py --write` locally and commit the result to this branch.\n\n' + + '
Diff that would be applied (truncated to 200 lines)\n\n```diff\n' + diff + '\n```\n
'; + } else { + body = marker + '\n\n✅ Tags/categories are in sync.'; + } + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body, + }); + } else if (hasDiff) { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body, + }); + } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..be62ba2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "json.schemas": [ + { + "fileMatch": ["/scripts.json"], + "url": "./scripts.schema.json" + } + ] +} diff --git a/scripts.schema.json b/scripts.schema.json new file mode 100644 index 0000000..1a625be --- /dev/null +++ b/scripts.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "EdgeTX Lua Scripts Gallery - scripts.json", + "description": "Schema for scripts.json. Also the canonical source for the known 'category' and 'tags' vocabularies, read and written at runtime by tools/sync_issue_template_options.py to keep the Issue Form templates in sync with actual usage in scripts.json. 'category.examples' and 'tags.items.examples' are machine-generated - do not hand-edit them, they will be overwritten on the next sync. category.examples grows monotonically (never auto-pruned); tags.items.examples is a pure snapshot of current usage (shrinks when a tag falls out of use).", + "type": "array", + "items": { + "type": "object", + "title": "ScriptEntry", + "required": ["name", "category", "description", "infourl", "tags"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Display name as shown in the gallery." + }, + "category": { + "type": "string", + "minLength": 1, + "description": "Gallery category - drives the site's top-level navigation tabs. Machine-generated examples list below (monotonic add-only, see tools/sync_issue_template_options.py).", + "examples": [ + "Audio & Media", + "Flight Controller Config", + "GPS & Mapping", + "Games & Fun", + "Logging & Analysis", + "Radio Tools", + "Telemetry & Widgets" + ] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Short 1-3 sentence description of the script." + }, + "infourl": { + "type": "string", + "format": "uri", + "description": "Link to the project page, GitHub repo, or documentation. Must start with http:// or https:// (enforced by tools/validate_scripts.py and tools/issue_to_scripts.py, not by this schema)." + }, + "images": { + "type": "array", + "items": { "type": "string" }, + "description": "Screenshot references: local ASSETS/ paths or external image URLs." + }, + "tags": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "description": "Machine-generated examples list below (current usage snapshot, see tools/sync_issue_template_options.py).", + "examples": [ + "ardupilot", + "battery", + "betaflight", + "black-and-white", + "color", + "crawler", + "crossfire", + "expresslrs", + "fixed-wing", + "game", + "gps", + "heli", + "inav", + "multi-protocol", + "quad", + "rotorflight", + "script", + "spektrum", + "tool", + "widget" + ] + } + } + }, + "additionalProperties": true + } +} diff --git a/tools/issue_to_scripts.py b/tools/issue_to_scripts.py index 152912d..9735f0d 100644 --- a/tools/issue_to_scripts.py +++ b/tools/issue_to_scripts.py @@ -20,16 +20,6 @@ import sys from pathlib import Path -VALID_CATEGORIES = { - "Audio & Media", - "Flight Controller Config", - "Games & Fun", - "GPS & Mapping", - "Logging & Analysis", - "Radio Tools", - "Telemetry & Widgets", -} - _NO_RESPONSE = "_no response_" @@ -88,6 +78,18 @@ def extract_extra_tags(text: str) -> list[str]: return [t.strip().lower() for t in text.split(",") if t.strip()] +def build_category(sections: dict[str, str]) -> str | None: + """ + Return the category to use: the free-text "New Category" field takes + precedence over the "Category" dropdown when both are filled in. + """ + new_category = sections.get("New Category", "").strip() + if not _is_empty(new_category): + return new_category + dropdown = sections.get("Category", "").strip() + return dropdown if not _is_empty(dropdown) else None + + def build_tags(sections: dict[str, str]) -> list[str] | None: """ Return the combined tag list, or None if the submitter left tags entirely @@ -114,11 +116,9 @@ def build_insert_entry(sections: dict[str, str]) -> dict: if not name: raise ValueError("App Name is required.") - category = sections.get("Category", "").strip() - if category not in VALID_CATEGORIES: - raise ValueError( - f"Category '{category}' is not valid. Must be one of: {sorted(VALID_CATEGORIES)}" - ) + category = build_category(sections) + if category is None: + raise ValueError("Category is required (pick one from the dropdown or fill in New Category).") description = sections.get("Description", "").strip() if _is_empty(description): @@ -194,13 +194,9 @@ def do_patch(scripts: list, sections: dict[str, str]) -> tuple[list, dict]: entry = dict(scripts[target_idx]) - raw_category = sections.get("Category", "").strip() - if raw_category and not _is_empty(raw_category): - if raw_category not in VALID_CATEGORIES: - raise ValueError( - f"Category '{raw_category}' is not valid. Must be one of: {sorted(VALID_CATEGORIES)}" - ) - entry["category"] = raw_category + new_category = build_category(sections) + if new_category is not None: + entry["category"] = new_category raw_description = sections.get("Description", "").strip() if not _is_empty(raw_description): diff --git a/tools/sync_issue_template_options.py b/tools/sync_issue_template_options.py new file mode 100644 index 0000000..7c1113e --- /dev/null +++ b/tools/sync_issue_template_options.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +""" +Keep the "Category" dropdown and "Tags" checkboxes in the Issue Form templates +(.github/ISSUE_TEMPLATE/add-script.yml, update-script.yml) in sync with +scripts.schema.json, which in turn tracks actual usage in scripts.json. + +Tags are a pure snapshot of current usage in scripts.json (a tag that stops +being used drops out on the next --write). Categories are monotonic add-only: +new categories used in scripts.json get promoted, but a category already known +is never removed just because it currently has zero entries — see the plan +this script was built from for why (categories drive the gallery's top-level +navigation tabs, and dropping one from the dropdown just because it's briefly +empty would be actively unhelpful). + +Usage: + uv run tools/sync_issue_template_options.py --check + uv run tools/sync_issue_template_options.py --write + +Exit codes: + 0 in sync (--check) or write completed/no-op (--write) + 1 drift found (--check only) + 2 file I/O error, malformed scripts.schema.json, or missing markers +""" + +import argparse +import difflib +import json +import re +import sys +from pathlib import Path + +TAG_BEGIN = "# --- BEGIN AUTO-GENERATED TAGS (see scripts.schema.json: items.properties.tags.items.examples) ---" +TAG_END = "# --- END AUTO-GENERATED TAGS ---" +CATEGORY_BEGIN = "# --- BEGIN AUTO-GENERATED CATEGORIES (see scripts.schema.json: items.properties.category.examples) ---" +CATEGORY_END = "# --- END AUTO-GENERATED CATEGORIES ---" + +DEFAULT_TEMPLATES = [ + Path(".github/ISSUE_TEMPLATE/add-script.yml"), + Path(".github/ISSUE_TEMPLATE/update-script.yml"), +] + +TAG_ITEM_RE = re.compile(r"^\s*-\s*label:\s*(.+?)\s*$") +CATEGORY_ITEM_RE = re.compile(r"^\s*-\s*(.+?)\s*$") + + +# ── Loading ────────────────────────────────────────────────────────────────── + +def load_scripts_json(path: Path) -> list: + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as e: + print(f"Error: unable to read/parse {path}: {e}", file=sys.stderr) + sys.exit(2) + if not isinstance(data, list): + print(f"Error: {path} must be a JSON array", file=sys.stderr) + sys.exit(2) + return data + + +def load_schema(path: Path) -> dict: + try: + with open(path, encoding="utf-8") as f: + schema = json.load(f) + except (OSError, json.JSONDecodeError) as e: + print(f"Error: unable to read/parse {path}: {e}", file=sys.stderr) + sys.exit(2) + try: + schema["items"]["properties"]["category"] + schema["items"]["properties"]["tags"]["items"] + except (KeyError, TypeError) as e: + print( + f"Error: {path} missing items.properties.category / " + f"items.properties.tags.items: {e}", + file=sys.stderr, + ) + sys.exit(2) + return schema + + +def get_schema_examples(schema: dict, field: str) -> list[str]: + """field: 'category' or 'tags'.""" + if field == "category": + node = schema["items"]["properties"]["category"] + else: + node = schema["items"]["properties"]["tags"]["items"] + examples = node.get("examples", []) + if not isinstance(examples, list) or not all(isinstance(x, str) for x in examples): + print( + f"Error: scripts.schema.json items.properties.{field} examples " + "must be a list of strings", + file=sys.stderr, + ) + sys.exit(2) + return examples + + +def set_schema_examples(schema: dict, field: str, values: list[str]) -> None: + if field == "category": + schema["items"]["properties"]["category"]["examples"] = values + else: + schema["items"]["properties"]["tags"]["items"]["examples"] = values + + +def save_schema(path: Path, schema: dict) -> None: + try: + path.write_text(json.dumps(schema, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + except OSError as e: + print(f"Error: unable to write {path}: {e}", file=sys.stderr) + sys.exit(2) + + +# ── Computation ────────────────────────────────────────────────────────────── + +def compute(scripts: list, schema: dict) -> dict: + current_tags = sorted({tag for e in scripts for tag in e.get("tags", []) if isinstance(tag, str)}) + + used_categories = sorted({ + e["category"] for e in scripts + if isinstance(e.get("category"), str) and e["category"].strip() + }) + previous_categories = get_schema_examples(schema, "category") + current_categories = sorted(set(previous_categories) | set(used_categories)) + new_categories = sorted(set(used_categories) - set(previous_categories)) + unused_categories = sorted(set(current_categories) - set(used_categories)) + + return { + "current_tags": current_tags, + "current_categories": current_categories, + "new_categories": new_categories, + "unused_categories": unused_categories, + } + + +# ── Marker-bounded block handling ─────────────────────────────────────────── + +def find_block(lines: list[str], begin: str, end: str, path: Path, block_name: str) -> tuple[int, int]: + begin_idx = next((i for i, line in enumerate(lines) if line.strip() == begin), None) + end_idx = next((i for i, line in enumerate(lines) if line.strip() == end), None) + if begin_idx is None or end_idx is None or end_idx <= begin_idx: + print( + f"Error: {path} is missing the {block_name} marker pair " + f"({begin!r} / {end!r})", + file=sys.stderr, + ) + sys.exit(2) + return begin_idx, end_idx + + +def block_indent(lines: list[str], begin_idx: int) -> str: + marker_line = lines[begin_idx] + return marker_line[: len(marker_line) - len(marker_line.lstrip())] + + +def render_tag_lines(indent: str, tags: list[str]) -> list[str]: + return [f"{indent}- label: {tag}" for tag in tags] + + +def render_category_lines(indent: str, categories: list[str]) -> list[str]: + return [f"{indent}- {category}" for category in categories] + + +def parse_block_values(lines: list[str], begin_idx: int, end_idx: int, item_re: re.Pattern) -> list[str]: + values = [] + for line in lines[begin_idx + 1:end_idx]: + m = item_re.match(line) + if m: + values.append(m.group(1).strip()) + return values + + +def rewrite_lines(lines: list[str], blocks: list[tuple[int, int, list[str]]]) -> list[str]: + """Apply multiple (begin_idx, end_idx, replacement_lines) edits in one pass, + computed against the original `lines` — never mutate indices between edits.""" + blocks = sorted(blocks, key=lambda b: b[0]) + result: list[str] = [] + cursor = 0 + for begin_idx, end_idx, replacement in blocks: + result.extend(lines[cursor:begin_idx + 1]) + result.extend(replacement) + result.append(lines[end_idx]) + cursor = end_idx + 1 + result.extend(lines[cursor:]) + return result + + +def compute_template_blocks( + template_path: Path, current_tags: list[str], current_categories: list[str] +) -> tuple[list[str], list[tuple[int, int, list[str], str, list[str]]]]: + """Returns (original_lines, [(begin_idx, end_idx, expected_replacement, block_name, actual_values), ...]).""" + try: + text = template_path.read_text(encoding="utf-8") + except OSError as e: + print(f"Error: unable to read {template_path}: {e}", file=sys.stderr) + sys.exit(2) + lines = text.splitlines() + + tag_begin, tag_end = find_block(lines, TAG_BEGIN, TAG_END, template_path, "tags") + cat_begin, cat_end = find_block(lines, CATEGORY_BEGIN, CATEGORY_END, template_path, "category") + + tag_indent = block_indent(lines, tag_begin) + cat_indent = block_indent(lines, cat_begin) + + tag_actual = parse_block_values(lines, tag_begin, tag_end, TAG_ITEM_RE) + cat_actual = parse_block_values(lines, cat_begin, cat_end, CATEGORY_ITEM_RE) + + blocks = [ + (tag_begin, tag_end, render_tag_lines(tag_indent, current_tags), "tags", tag_actual), + (cat_begin, cat_end, render_category_lines(cat_indent, current_categories), "category", cat_actual), + ] + return lines, blocks + + +def check_template(template_path: Path, current_tags: list[str], current_categories: list[str]) -> list[str]: + """Returns a list of human-readable issue descriptions (empty if in sync).""" + lines, blocks = compute_template_blocks(template_path, current_tags, current_categories) + issues = [] + for begin_idx, end_idx, expected, block_name, actual in blocks: + expected_values = current_tags if block_name == "tags" else current_categories + if actual == expected_values: + continue + missing = sorted(set(expected_values) - set(actual)) + extra = sorted(set(actual) - set(expected_values)) + diff = "\n".join( + difflib.unified_diff( + lines[begin_idx:end_idx + 1], + [lines[begin_idx]] + expected + [lines[end_idx]], + fromfile=f"{template_path} [{block_name}] (current)", + tofile=f"{template_path} [{block_name}] (expected)", + lineterm="", + ) + ) + issues.append( + f"{template_path} [{block_name}]: out of sync\n" + f" missing: {missing}\n" + f" extra: {extra}\n" + f"{diff}" + ) + return issues + + +def write_template(template_path: Path, current_tags: list[str], current_categories: list[str]) -> bool: + """Returns True if the file changed.""" + lines, blocks = compute_template_blocks(template_path, current_tags, current_categories) + rewrite_blocks = [(b, e, replacement) for b, e, replacement, _, _ in blocks] + new_lines = rewrite_lines(lines, rewrite_blocks) + new_text = "\n".join(new_lines) + "\n" + old_text = template_path.read_text(encoding="utf-8") + if new_text == old_text: + return False + try: + template_path.write_text(new_text, encoding="utf-8") + except OSError as e: + print(f"Error: unable to write {template_path}: {e}", file=sys.stderr) + sys.exit(2) + return True + + +# ── Main ───────────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--scripts-json", default=Path("scripts.json"), type=Path) + parser.add_argument("--schema", default=Path("scripts.schema.json"), type=Path) + parser.add_argument("--templates", nargs="+", type=Path, default=DEFAULT_TEMPLATES) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true", help="Exit 1 if anything is out of sync") + mode.add_argument("--write", action="store_true", help="Regenerate schema examples and template blocks in place") + args = parser.parse_args() + + scripts = load_scripts_json(args.scripts_json) + schema = load_schema(args.schema) + computed = compute(scripts, schema) + current_tags = computed["current_tags"] + current_categories = computed["current_categories"] + + if args.check: + issues = [] + + schema_tags = get_schema_examples(schema, "tags") + if sorted(schema_tags) != current_tags: + issues.append( + f"{args.schema} [tags.examples]: out of sync\n" + f" missing: {sorted(set(current_tags) - set(schema_tags))}\n" + f" extra: {sorted(set(schema_tags) - set(current_tags))}" + ) + schema_categories = get_schema_examples(schema, "category") + if sorted(schema_categories) != current_categories: + issues.append( + f"{args.schema} [category.examples]: out of sync\n" + f" missing: {sorted(set(current_categories) - set(schema_categories))}\n" + f" extra: {sorted(set(schema_categories) - set(current_categories))}" + ) + + for template_path in args.templates: + issues.extend(check_template(template_path, current_tags, current_categories)) + + if issues: + print(f"Found {len(issues)} out-of-sync region(s):") + for issue in issues: + print(issue) + print() + sys.exit(1) + + print("All tags/category regions are in sync.") + return + + # --write + changed_anything = False + + schema_tags = get_schema_examples(schema, "tags") + schema_categories = get_schema_examples(schema, "category") + if sorted(schema_tags) != current_tags or sorted(schema_categories) != current_categories: + set_schema_examples(schema, "tags", current_tags) + set_schema_examples(schema, "category", current_categories) + save_schema(args.schema, schema) + changed_anything = True + print(f"Updated {args.schema}") + + for template_path in args.templates: + if write_template(template_path, current_tags, current_categories): + changed_anything = True + print(f"Updated {template_path}") + + if computed["new_categories"]: + print(f"new_categories={','.join(computed['new_categories'])}") + if computed["unused_categories"]: + print(f"unused_categories={','.join(computed['unused_categories'])}") + + if not changed_anything: + print("Already in sync, nothing written.") + + +if __name__ == "__main__": + main() diff --git a/tools/test_sync_issue_template_options.py b/tools/test_sync_issue_template_options.py new file mode 100644 index 0000000..313510e --- /dev/null +++ b/tools/test_sync_issue_template_options.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +""" +Tests for tools/sync_issue_template_options.py — the multi-region marker +splicing and monotonic-add category logic are fiddly enough to be worth +covering with something more than manual verification. + +Usage: + uv run tools/test_sync_issue_template_options.py + python3 -m unittest tools/test_sync_issue_template_options.py +""" + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).parent / "sync_issue_template_options.py" + +TEMPLATE_FIXTURE = """\ +name: Test Template +body: + - type: dropdown + id: category + attributes: + options: + # --- BEGIN AUTO-GENERATED CATEGORIES (see scripts.schema.json: items.properties.category.examples) --- +{category_lines} + # --- END AUTO-GENERATED CATEGORIES --- + - type: checkboxes + id: tags_common + attributes: + options: + # --- BEGIN AUTO-GENERATED TAGS (see scripts.schema.json: items.properties.tags.items.examples) --- +{tag_lines} + # --- END AUTO-GENERATED TAGS --- +""" + +TEMPLATE_NO_MARKERS = """\ +name: Test Template +body: + - type: dropdown + id: category + attributes: + options: + - Cat A +""" + + +def render_category_lines(categories: list[str]) -> str: + return "\n".join(f" - {c}" for c in categories) + + +def render_tag_lines(tags: list[str]) -> str: + return "\n".join(f" - label: {t}" for t in tags) + + +class SyncFixture: + """Builds a scratch directory with scripts.json / scripts.schema.json / + two template files, so tests never touch the real repo files.""" + + def __init__(self, tmpdir: Path, entries: list[dict], schema_categories: list[str], + template_categories: list[str] | None = None, + template_tags: list[str] | None = None): + self.dir = tmpdir + self.scripts_json = tmpdir / "scripts.json" + self.scripts_json.write_text(json.dumps(entries), encoding="utf-8") + + used_tags = sorted({t for e in entries for t in e.get("tags", [])}) + self.schema_json = tmpdir / "scripts.schema.json" + schema = { + "items": { + "properties": { + "category": {"type": "string", "examples": schema_categories}, + "tags": {"type": "array", "items": {"type": "string", "examples": used_tags}}, + } + } + } + self.schema_json.write_text(json.dumps(schema), encoding="utf-8") + + tmpl_categories = template_categories if template_categories is not None else schema_categories + tmpl_tags = template_tags if template_tags is not None else used_tags + template_text = TEMPLATE_FIXTURE.format( + category_lines=render_category_lines(tmpl_categories), + tag_lines=render_tag_lines(tmpl_tags), + ) + self.template_a = tmpdir / "template_a.yml" + self.template_b = tmpdir / "template_b.yml" + self.template_a.write_text(template_text, encoding="utf-8") + self.template_b.write_text(template_text, encoding="utf-8") + + def run(self, *extra_args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [ + sys.executable, str(SCRIPT), + "--scripts-json", str(self.scripts_json), + "--schema", str(self.schema_json), + "--templates", str(self.template_a), str(self.template_b), + *extra_args, + ], + capture_output=True, text=True, + ) + + def schema_examples(self) -> tuple[list[str], list[str]]: + schema = json.loads(self.schema_json.read_text(encoding="utf-8")) + return schema["items"]["properties"]["tags"]["items"]["examples"], \ + schema["items"]["properties"]["category"]["examples"] + + +class TestSync(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmpdir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def test_check_in_sync(self): + entries = [{"category": "Cat A", "tags": ["tag-a"]}] + fx = SyncFixture(self.tmpdir, entries, schema_categories=["Cat A"]) + result = fx.run("--check") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("in sync", result.stdout) + + def test_check_detects_new_tag(self): + entries = [{"category": "Cat A", "tags": ["tag-a", "tag-new"]}] + fx = SyncFixture(self.tmpdir, entries, schema_categories=["Cat A"], + template_tags=["tag-a"]) + result = fx.run("--check") + self.assertEqual(result.returncode, 1) + self.assertIn("[tags]", result.stdout) + + def test_check_detects_new_category(self): + entries = [{"category": "Cat B", "tags": ["tag-a"]}] + fx = SyncFixture(self.tmpdir, entries, schema_categories=["Cat A"], + template_categories=["Cat A"]) + result = fx.run("--check") + self.assertEqual(result.returncode, 1) + self.assertIn("[category]", result.stdout) + + def test_write_promotes_new_tag_and_category(self): + entries = [{"category": "Cat A", "tags": ["tag-a"]}, + {"category": "Cat New", "tags": ["tag-new"]}] + fx = SyncFixture(self.tmpdir, entries, schema_categories=["Cat A"], + template_categories=["Cat A"], template_tags=["tag-a"]) + result = fx.run("--write") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + tags, categories = fx.schema_examples() + self.assertEqual(tags, ["tag-a", "tag-new"]) + self.assertEqual(categories, ["Cat A", "Cat New"]) + self.assertIn("- Cat New", fx.template_a.read_text()) + self.assertIn("- label: tag-new", fx.template_a.read_text()) + self.assertIn("new_categories=Cat New", result.stdout) + + def test_category_removal_is_monotonic(self): + # Cat B is known (schema) but no entry currently uses it. + entries = [{"category": "Cat A", "tags": ["tag-a"]}] + fx = SyncFixture(self.tmpdir, entries, schema_categories=["Cat A", "Cat B"], + template_categories=["Cat A", "Cat B"]) + result = fx.run("--write") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + _, categories = fx.schema_examples() + self.assertIn("Cat B", categories, "unused category must not be pruned automatically") + self.assertIn("- Cat B", fx.template_a.read_text()) + self.assertIn("unused_categories=Cat B", result.stdout) + + def test_tag_removal_drops_from_checkboxes(self): + # tag-old is in the schema/templates but no entry currently uses it. + entries = [{"category": "Cat A", "tags": ["tag-a"]}] + fx = SyncFixture(self.tmpdir, entries, schema_categories=["Cat A"], + template_tags=["tag-a", "tag-old"]) + # seed schema with tag-old too, so we can prove it gets dropped + schema = json.loads(fx.schema_json.read_text()) + schema["items"]["properties"]["tags"]["items"]["examples"] = ["tag-a", "tag-old"] + fx.schema_json.write_text(json.dumps(schema)) + + result = fx.run("--write") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + tags, _ = fx.schema_examples() + self.assertNotIn("tag-old", tags, "unused tag should drop out (unlike categories)") + self.assertNotIn("tag-old", fx.template_a.read_text()) + + def test_multi_region_splice_independence(self): + entries = [{"category": "Cat New", "tags": ["tag-new"]}] + fx = SyncFixture(self.tmpdir, entries, schema_categories=["Cat A"], + template_categories=["Cat A"], template_tags=["tag-a"]) + result = fx.run("--write") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + text = fx.template_a.read_text() + # both blocks must have updated correctly, and the file must still be + # well-formed (markers present, no corruption from index drift). + self.assertIn("- Cat New", text) + self.assertIn("- label: tag-new", text) + self.assertEqual(text.count("BEGIN AUTO-GENERATED CATEGORIES"), 1) + self.assertEqual(text.count("BEGIN AUTO-GENERATED TAGS"), 1) + self.assertEqual(text.count("END AUTO-GENERATED CATEGORIES"), 1) + self.assertEqual(text.count("END AUTO-GENERATED TAGS"), 1) + + def test_missing_markers_errors_cleanly(self): + entries = [{"category": "Cat A", "tags": ["tag-a"]}] + fx = SyncFixture(self.tmpdir, entries, schema_categories=["Cat A"]) + fx.template_a.write_text(TEMPLATE_NO_MARKERS, encoding="utf-8") + result = fx.run("--check") + self.assertEqual(result.returncode, 2) + self.assertIn("marker", result.stderr) + + def test_write_is_idempotent(self): + entries = [{"category": "Cat A", "tags": ["tag-a", "tag-b"]}] + fx = SyncFixture(self.tmpdir, entries, schema_categories=["Cat A"], + template_tags=["tag-a"]) + first = fx.run("--write") + self.assertEqual(first.returncode, 0) + second = fx.run("--write") + self.assertEqual(second.returncode, 0) + self.assertIn("Already in sync", second.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate_scripts.py b/tools/validate_scripts.py index fe82ae1..d7b1382 100644 --- a/tools/validate_scripts.py +++ b/tools/validate_scripts.py @@ -10,16 +10,6 @@ import sys from pathlib import Path -KNOWN_CATEGORIES = { - "Audio & Media", - "Flight Controller Config", - "Games & Fun", - "GPS & Mapping", - "Logging & Analysis", - "Radio Tools", - "Telemetry & Widgets", -} - REQUIRED_FIELDS = ["name", "category", "description", "infourl", "tags"] STRING_FIELDS = ["name", "category", "description", "infourl"] @@ -64,13 +54,6 @@ def validate(data: list) -> tuple[list[str], list[str]]: if not isinstance(entry[field], str) or not entry[field].strip(): errors.append(f"{prefix}: '{field}' must be a non-empty string") - category = entry.get("category") - if isinstance(category, str) and category.strip() and category not in KNOWN_CATEGORIES: - errors.append( - f"{prefix}: unknown category '{category}'" - f" (known: {sorted(KNOWN_CATEGORIES)})" - ) - infourl = entry.get("infourl") if isinstance(infourl, str) and infourl.strip(): if not (infourl.startswith("http://") or infourl.startswith("https://")): From 3c86f45e10691a1ef684bb27d19bb17648ff4f9a Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:35:30 +1000 Subject: [PATCH 2/6] fix(ci): disable uv dependency caching in issue-template workflow No lock/requirements file exists in this repo for setup-uv to key a cache on, so it was warning that the cache could never invalidate. There's nothing to actually cache (the tools/*.py scripts are stdlib-only), so disable it. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/validate-issue-templates.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/validate-issue-templates.yml b/.github/workflows/validate-issue-templates.yml index 503943c..2dc7fd9 100644 --- a/.github/workflows/validate-issue-templates.yml +++ b/.github/workflows/validate-issue-templates.yml @@ -42,6 +42,8 @@ jobs: steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: false - name: Run sync script tests run: uv run tools/test_sync_issue_template_options.py - name: Check tags/category sync @@ -59,6 +61,8 @@ jobs: steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: false - name: Run sync script tests run: uv run tools/test_sync_issue_template_options.py - name: Sync tags/category @@ -102,6 +106,8 @@ jobs: # job to execute. - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: false - name: Fetch PR's scripts.json as data only (never executed) run: | git fetch origin "pull/${{ github.event.pull_request.number }}/head" From 6bf2488038b62ab87f92fe01caeb6d9a8bea1382 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:37:18 +1000 Subject: [PATCH 3/6] fix(ci): disable uv dependency caching in validate-scripts-json workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as the issue-template workflow — no lock/requirements file exists for setup-uv to key a cache on, so it was warning the cache could never invalidate. Nothing to cache here either. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/validate-scripts-json.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/validate-scripts-json.yml b/.github/workflows/validate-scripts-json.yml index 9e15984..56fe32a 100644 --- a/.github/workflows/validate-scripts-json.yml +++ b/.github/workflows/validate-scripts-json.yml @@ -19,5 +19,7 @@ jobs: steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: false - name: Validate scripts.json run: uv run tools/validate_scripts.py --scripts-json scripts.json From 9266449c7b9d31a330ba97afba316ae045c79fc0 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:38:20 +1000 Subject: [PATCH 4/6] fix(ci): disable uv dependency caching in remaining workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the same fix for gh-pages.yml and script-submission.yml — no lock/requirements file exists for setup-uv to key a cache on in this repo, so it was warning the cache could never invalidate. Nothing to cache here. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/gh-pages.yml | 2 ++ .github/workflows/script-submission.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 2e87263..52b706a 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -31,6 +31,8 @@ jobs: steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: false - name: Generate site run: uv run tools/generate_site.py --scripts-json scripts.json --assets-dir ASSETS --output-dir site - name: Upload Pages artifact diff --git a/.github/workflows/script-submission.yml b/.github/workflows/script-submission.yml index 85e38dc..e41240b 100644 --- a/.github/workflows/script-submission.yml +++ b/.github/workflows/script-submission.yml @@ -21,6 +21,8 @@ jobs: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: false - name: Write issue body to file env: From 98885eecc1691fed12e50f2e9aefe64f072d87df Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:43:36 +1000 Subject: [PATCH 5/6] fix(tools): sort tags/categories case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain sorted() put 'GPS & Mapping' before 'Games & Fun' (uppercase 'P' < lowercase 'a' in ASCII) — not the order a human reading the dropdown would expect. Also fixes a related bug the case-insensitive change would otherwise introduce: the schema-vs-current comparisons re-sorted both sides before comparing, which hid ordering drift in scripts.schema.json's own examples arrays (they're supposed to already hold the canonical sorted list, so a stale on-disk order needs to be caught, not normalized away). Co-Authored-By: Claude Sonnet 5 --- .github/ISSUE_TEMPLATE/add-script.yml | 2 +- .github/ISSUE_TEMPLATE/update-script.yml | 2 +- scripts.schema.json | 14 +++++++-- tools/sync_issue_template_options.py | 40 +++++++++++++++--------- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/add-script.yml b/.github/ISSUE_TEMPLATE/add-script.yml index 2f2ef4a..39bf38d 100644 --- a/.github/ISSUE_TEMPLATE/add-script.yml +++ b/.github/ISSUE_TEMPLATE/add-script.yml @@ -32,8 +32,8 @@ body: # --- BEGIN AUTO-GENERATED CATEGORIES (see scripts.schema.json: items.properties.category.examples) --- - Audio & Media - Flight Controller Config - - GPS & Mapping - Games & Fun + - GPS & Mapping - Logging & Analysis - Radio Tools - Telemetry & Widgets diff --git a/.github/ISSUE_TEMPLATE/update-script.yml b/.github/ISSUE_TEMPLATE/update-script.yml index ccf7f20..fb6ae48 100644 --- a/.github/ISSUE_TEMPLATE/update-script.yml +++ b/.github/ISSUE_TEMPLATE/update-script.yml @@ -30,8 +30,8 @@ body: # --- BEGIN AUTO-GENERATED CATEGORIES (see scripts.schema.json: items.properties.category.examples) --- - Audio & Media - Flight Controller Config - - GPS & Mapping - Games & Fun + - GPS & Mapping - Logging & Analysis - Radio Tools - Telemetry & Widgets diff --git a/scripts.schema.json b/scripts.schema.json index 1a625be..6409cb2 100644 --- a/scripts.schema.json +++ b/scripts.schema.json @@ -6,7 +6,13 @@ "items": { "type": "object", "title": "ScriptEntry", - "required": ["name", "category", "description", "infourl", "tags"], + "required": [ + "name", + "category", + "description", + "infourl", + "tags" + ], "properties": { "name": { "type": "string", @@ -20,8 +26,8 @@ "examples": [ "Audio & Media", "Flight Controller Config", - "GPS & Mapping", "Games & Fun", + "GPS & Mapping", "Logging & Analysis", "Radio Tools", "Telemetry & Widgets" @@ -39,7 +45,9 @@ }, "images": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Screenshot references: local ASSETS/ paths or external image URLs." }, "tags": { diff --git a/tools/sync_issue_template_options.py b/tools/sync_issue_template_options.py index 7c1113e..9f638e6 100644 --- a/tools/sync_issue_template_options.py +++ b/tools/sync_issue_template_options.py @@ -112,17 +112,24 @@ def save_schema(path: Path, schema: dict) -> None: # ── Computation ────────────────────────────────────────────────────────────── +def _sort(values) -> list[str]: + """Case-insensitive sort — plain sorted() would put e.g. 'GPS & Mapping' + before 'Games & Fun' (uppercase 'P' < lowercase 'a' in ASCII), which + isn't the order a human reading the dropdown/checkbox list would expect.""" + return sorted(values, key=str.casefold) + + def compute(scripts: list, schema: dict) -> dict: - current_tags = sorted({tag for e in scripts for tag in e.get("tags", []) if isinstance(tag, str)}) + current_tags = _sort({tag for e in scripts for tag in e.get("tags", []) if isinstance(tag, str)}) - used_categories = sorted({ + used_categories = _sort({ e["category"] for e in scripts if isinstance(e.get("category"), str) and e["category"].strip() }) previous_categories = get_schema_examples(schema, "category") - current_categories = sorted(set(previous_categories) | set(used_categories)) - new_categories = sorted(set(used_categories) - set(previous_categories)) - unused_categories = sorted(set(current_categories) - set(used_categories)) + current_categories = _sort(set(previous_categories) | set(used_categories)) + new_categories = _sort(set(used_categories) - set(previous_categories)) + unused_categories = _sort(set(current_categories) - set(used_categories)) return { "current_tags": current_tags, @@ -219,8 +226,8 @@ def check_template(template_path: Path, current_tags: list[str], current_categor expected_values = current_tags if block_name == "tags" else current_categories if actual == expected_values: continue - missing = sorted(set(expected_values) - set(actual)) - extra = sorted(set(actual) - set(expected_values)) + missing = _sort(set(expected_values) - set(actual)) + extra = _sort(set(actual) - set(expected_values)) diff = "\n".join( difflib.unified_diff( lines[begin_idx:end_idx + 1], @@ -277,19 +284,24 @@ def main() -> None: if args.check: issues = [] + # Direct (order-sensitive) comparison, not sorted() on both sides — + # the schema's examples arrays are always supposed to already hold + # the canonical _sort()-ordered list, so a stale *order* on disk + # (e.g. written before a sort-key change) must count as drift too, + # not just a stale *set* of values. schema_tags = get_schema_examples(schema, "tags") - if sorted(schema_tags) != current_tags: + if schema_tags != current_tags: issues.append( f"{args.schema} [tags.examples]: out of sync\n" - f" missing: {sorted(set(current_tags) - set(schema_tags))}\n" - f" extra: {sorted(set(schema_tags) - set(current_tags))}" + f" missing: {_sort(set(current_tags) - set(schema_tags))}\n" + f" extra: {_sort(set(schema_tags) - set(current_tags))}" ) schema_categories = get_schema_examples(schema, "category") - if sorted(schema_categories) != current_categories: + if schema_categories != current_categories: issues.append( f"{args.schema} [category.examples]: out of sync\n" - f" missing: {sorted(set(current_categories) - set(schema_categories))}\n" - f" extra: {sorted(set(schema_categories) - set(current_categories))}" + f" missing: {_sort(set(current_categories) - set(schema_categories))}\n" + f" extra: {_sort(set(schema_categories) - set(current_categories))}" ) for template_path in args.templates: @@ -310,7 +322,7 @@ def main() -> None: schema_tags = get_schema_examples(schema, "tags") schema_categories = get_schema_examples(schema, "category") - if sorted(schema_tags) != current_tags or sorted(schema_categories) != current_categories: + if schema_tags != current_tags or schema_categories != current_categories: set_schema_examples(schema, "tags", current_tags) set_schema_examples(schema, "category", current_categories) save_schema(args.schema, schema) From cec22db45b3153678d34f386fe694bdbeb7f0bb9 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:45:57 +1000 Subject: [PATCH 6/6] feat(tools): add --prune-categories to sync script Removing a stale, currently-unused category previously meant hand-editing scripts.schema.json's examples array directly. --prune-categories overrides the normal monotonic-add-only behavior for a single invocation, removing categories with zero current scripts.json entries from the schema and both templates in the same pass as a normal --write. Co-Authored-By: Claude Sonnet 5 --- tools/sync_issue_template_options.py | 25 +++++++++++++++++++---- tools/test_sync_issue_template_options.py | 14 ++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tools/sync_issue_template_options.py b/tools/sync_issue_template_options.py index 9f638e6..6ebf5c0 100644 --- a/tools/sync_issue_template_options.py +++ b/tools/sync_issue_template_options.py @@ -10,11 +10,14 @@ is never removed just because it currently has zero entries — see the plan this script was built from for why (categories drive the gallery's top-level navigation tabs, and dropping one from the dropdown just because it's briefly -empty would be actively unhelpful). +empty would be actively unhelpful). Pass --prune-categories to deliberately +override that and remove currently-unused categories too. Usage: uv run tools/sync_issue_template_options.py --check uv run tools/sync_issue_template_options.py --write + uv run tools/sync_issue_template_options.py --check --prune-categories + uv run tools/sync_issue_template_options.py --write --prune-categories Exit codes: 0 in sync (--check) or write completed/no-op (--write) @@ -119,7 +122,7 @@ def _sort(values) -> list[str]: return sorted(values, key=str.casefold) -def compute(scripts: list, schema: dict) -> dict: +def compute(scripts: list, schema: dict, prune_categories: bool = False) -> dict: current_tags = _sort({tag for e in scripts for tag in e.get("tags", []) if isinstance(tag, str)}) used_categories = _sort({ @@ -127,7 +130,12 @@ def compute(scripts: list, schema: dict) -> dict: if isinstance(e.get("category"), str) and e["category"].strip() }) previous_categories = get_schema_examples(schema, "category") - current_categories = _sort(set(previous_categories) | set(used_categories)) + if prune_categories: + # Deliberate override of the normal monotonic-add-only behavior — + # only takes effect when explicitly requested (--prune-categories). + current_categories = used_categories + else: + current_categories = _sort(set(previous_categories) | set(used_categories)) new_categories = _sort(set(used_categories) - set(previous_categories)) unused_categories = _sort(set(current_categories) - set(used_categories)) @@ -270,6 +278,15 @@ def main() -> None: parser.add_argument("--scripts-json", default=Path("scripts.json"), type=Path) parser.add_argument("--schema", default=Path("scripts.schema.json"), type=Path) parser.add_argument("--templates", nargs="+", type=Path, default=DEFAULT_TEMPLATES) + parser.add_argument( + "--prune-categories", action="store_true", + help=( + "Also remove categories with zero current entries in scripts.json, " + "overriding the normal monotonic-add-only behavior. Use deliberately " + "(e.g. after confirming a category should be retired) — combine with " + "--check first to preview what would be pruned." + ), + ) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--check", action="store_true", help="Exit 1 if anything is out of sync") mode.add_argument("--write", action="store_true", help="Regenerate schema examples and template blocks in place") @@ -277,7 +294,7 @@ def main() -> None: scripts = load_scripts_json(args.scripts_json) schema = load_schema(args.schema) - computed = compute(scripts, schema) + computed = compute(scripts, schema, prune_categories=args.prune_categories) current_tags = computed["current_tags"] current_categories = computed["current_categories"] diff --git a/tools/test_sync_issue_template_options.py b/tools/test_sync_issue_template_options.py index 313510e..79830eb 100644 --- a/tools/test_sync_issue_template_options.py +++ b/tools/test_sync_issue_template_options.py @@ -163,7 +163,19 @@ def test_category_removal_is_monotonic(self): _, categories = fx.schema_examples() self.assertIn("Cat B", categories, "unused category must not be pruned automatically") self.assertIn("- Cat B", fx.template_a.read_text()) - self.assertIn("unused_categories=Cat B", result.stdout) + + def test_prune_categories_flag_removes_unused(self): + # Same setup as test_category_removal_is_monotonic, but explicitly + # asking to prune should remove Cat B this time. + entries = [{"category": "Cat A", "tags": ["tag-a"]}] + fx = SyncFixture(self.tmpdir, entries, schema_categories=["Cat A", "Cat B"], + template_categories=["Cat A", "Cat B"]) + result = fx.run("--write", "--prune-categories") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + _, categories = fx.schema_examples() + self.assertNotIn("Cat B", categories, "--prune-categories should remove unused categories") + self.assertNotIn("- Cat B", fx.template_a.read_text()) + self.assertNotIn("unused_categories=", result.stdout) def test_tag_removal_drops_from_checkboxes(self): # tag-old is in the schema/templates but no entry currently uses it.