diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a60b7a0306..91faf3a86c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,8 @@ # Global code owner * @mnriem +# Community catalog files — require maintainer approval even for bot PRs +extensions/catalog.community.json @mnriem +integrations/catalog.community.json @mnriem +presets/catalog.community.json @mnriem + diff --git a/.github/ISSUE_TEMPLATE/preset_submission.yml b/.github/ISSUE_TEMPLATE/preset_submission.yml index 3a1b963492..4d6b25bb15 100644 --- a/.github/ISSUE_TEMPLATE/preset_submission.yml +++ b/.github/ISSUE_TEMPLATE/preset_submission.yml @@ -95,17 +95,22 @@ body: validations: required: true + - type: input + id: required-extensions + attributes: + label: Required Extensions (optional) + description: Comma-separated list of required extension IDs (e.g., aide) + placeholder: "e.g., aide, canon" + - type: textarea id: templates-provided attributes: label: Templates Provided - description: List the template overrides your preset provides + description: List the template overrides your preset provides (leave empty for command-only presets) placeholder: | - spec-template.md — adds compliance section - plan-template.md — includes audit checkpoints - checklist-template.md — HIPAA compliance checklist - validations: - required: true - type: textarea id: commands-provided @@ -115,6 +120,13 @@ body: placeholder: | - speckit.specify.md — customized for compliance workflows + - type: input + id: scripts-count + attributes: + label: Number of Scripts (optional) + description: How many scripts does your preset provide? (leave empty if none) + placeholder: "e.g., 1" + - type: textarea id: tags attributes: diff --git a/.github/scripts/catalog-generate-table.py b/.github/scripts/catalog-generate-table.py new file mode 100644 index 0000000000..fc3c0666cf --- /dev/null +++ b/.github/scripts/catalog-generate-table.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Generate a markdown table from a community catalog JSON file. + +Reads a catalog.community.json and replaces content between marker comments +in a target markdown file. When ``--target`` is provided and markers are +missing, the script exits with an error. Without ``--target`` the table is +printed to stdout. + +Markers expected in the markdown file: + + ... (old table content replaced) ... + + +Usage: + python .github/scripts/catalog-generate-table.py \ + --catalog presets/catalog.community.json \ + --type preset \ + --target docs/community/presets.md +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +START_MARKER = "" +END_MARKER = "" + + +# --------------------------------------------------------------------------- +# Table builders — one per catalog type +# --------------------------------------------------------------------------- + +def _repo_display_name(url: str) -> str: + """Extract the repository name from a GitHub URL.""" + # https://github.com/user/spec-kit-foo → spec-kit-foo + return url.rstrip("/").rsplit("/", 1)[-1] + + +def _provides_str_preset(provides: dict) -> str: + parts: list[str] = [] + t = provides.get("templates", 0) + c = provides.get("commands", 0) + s = provides.get("scripts", 0) + if t: + parts.append(f"{t} template{'s' if t != 1 else ''}") + if c: + parts.append(f"{c} command{'s' if c != 1 else ''}") + if s: + parts.append(f"{s} script{'s' if s != 1 else ''}") + return ", ".join(parts) or "—" + + +def _requires_str_preset(requires: dict) -> str: + exts = requires.get("extensions", []) + if exts: + return ", ".join(f"{e} extension" for e in exts) + return "—" + + +def _escape_cell(value: str) -> str: + """Escape pipe characters and collapse whitespace for markdown table cells.""" + return value.replace("|", "\\|").replace("\n", " ").replace("\r", "").strip() + + +def build_preset_table(catalog: dict) -> str: + """Build a markdown table for presets.""" + entries = catalog.get("presets", {}) + lines: list[str] = [] + lines.append("| Preset | Purpose | Provides | Requires | URL |") + lines.append("|--------|---------|----------|----------|-----|") + + for _id in sorted(entries): + e = entries[_id] + name = _escape_cell(e.get("name", _id)) + desc = _escape_cell(e.get("description", "")) + provides = _provides_str_preset(e.get("provides", {})) + requires = _requires_str_preset(e.get("requires", {})) + repo_url = e.get("repository", "") + repo_name = _repo_display_name(repo_url) + lines.append( + f"| {name} | {desc} | {provides} | {requires} " + f"| [{repo_name}]({repo_url}) |" + ) + + return "\n".join(lines) + + +def _provides_str_extension(provides: dict) -> str: + parts: list[str] = [] + c = provides.get("commands", 0) + h = provides.get("hooks", 0) + if c: + parts.append(f"{c} command{'s' if c != 1 else ''}") + if h: + parts.append(f"{h} hook{'s' if h != 1 else ''}") + return ", ".join(parts) or "—" + + +def build_extension_table(catalog: dict) -> str: + """Build a markdown table for extensions. + + Note: Category and Effect columns will be empty unless the catalog + entries include ``category`` and ``effect`` fields (not yet part of + the standard catalog schema). + """ + entries = catalog.get("extensions", {}) + lines: list[str] = [] + lines.append("| Extension | Purpose | Category | Effect | URL |") + lines.append("|-----------|---------|----------|--------|-----|") + + for _id in sorted(entries): + e = entries[_id] + name = _escape_cell(e.get("name", _id)) + desc = _escape_cell(e.get("description", "")) + category = _escape_cell(e.get("category", "")) + if category: + category = f"`{category}`" + effect = _escape_cell(e.get("effect", "")) + repo_url = e.get("repository", "") + repo_name = _repo_display_name(repo_url) + lines.append( + f"| {name} | {desc} | {category} | {effect} " + f"| [{repo_name}]({repo_url}) |" + ) + + return "\n".join(lines) + + +BUILDERS = { + "preset": build_preset_table, + "extension": build_extension_table, +} + + +# --------------------------------------------------------------------------- +# File updater +# --------------------------------------------------------------------------- + +def update_file(path: Path, table: str) -> bool: + """Replace content between markers in *path*. Returns True if updated.""" + content = path.read_text() + + pattern = re.compile( + rf"({re.escape(START_MARKER)})\n.*?\n({re.escape(END_MARKER)})", + re.DOTALL, + ) + + if not pattern.search(content): + return False + + new_content = pattern.sub(lambda m: f"{m.group(1)}\n{table}\n{m.group(2)}", content) + + if new_content != content: + path.write_text(new_content) + return True + return False + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--catalog", required=True, + help="Path to catalog.community.json", + ) + parser.add_argument( + "--type", required=True, choices=list(BUILDERS), + help="Catalog type", + ) + parser.add_argument( + "--target", + help="Markdown file to update (must contain marker comments)", + ) + args = parser.parse_args() + + with open(args.catalog) as f: + catalog = json.load(f) + + builder = BUILDERS[args.type] + table = builder(catalog) + + if args.target: + target = Path(args.target) + if not target.exists(): + print(f"Error: target file not found: {target}", file=sys.stderr) + sys.exit(1) + if update_file(target, table): + print(f"Updated {target}") + else: + print( + f"Error: markers {START_MARKER} / {END_MARKER} not found " + f"in {target}.", + file=sys.stderr, + ) + sys.exit(1) + else: + print(table) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/catalog-pr.py b/.github/scripts/catalog-pr.py new file mode 100644 index 0000000000..bfa7832726 --- /dev/null +++ b/.github/scripts/catalog-pr.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Update a community catalog JSON file with a validated entry. + +Reads the validation result and entry produced by catalog-validate.py, +inserts or replaces the entry in the catalog, sorts entries alphabetically, +and optionally regenerates a docs table. + +Usage (typically called from GitHub Actions): + python .github/scripts/catalog-pr.py \ + --catalog extensions/catalog.community.json \ + --type extension + + python .github/scripts/catalog-pr.py \ + --catalog presets/catalog.community.json \ + --type preset \ + --table-target docs/community/presets.md + +Environment variables: + GITHUB_OUTPUT — Path to GitHub Actions output file (optional) + +Inputs (files produced by catalog-validate.py): + /tmp/validation-result.json — metadata including item_id, valid, is_update + /tmp/catalog-entry.json — the entry to insert/replace +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +CATALOG_KEY = { + "extension": "extensions", + "preset": "presets", +} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--catalog", required=True, + help="Path to the catalog JSON file", + ) + parser.add_argument( + "--type", required=True, choices=list(CATALOG_KEY), + help="Catalog type", + ) + parser.add_argument( + "--result", default="/tmp/validation-result.json", + help="Path to validation result JSON", + ) + parser.add_argument( + "--entry", default="/tmp/catalog-entry.json", + help="Path to catalog entry JSON", + ) + parser.add_argument( + "--table-target", + help="Markdown file to regenerate table in (must contain marker comments)", + ) + args = parser.parse_args() + + # Load validation result + result_path = Path(args.result) + if not result_path.exists(): + print(f"Error: result file not found: {result_path}", file=sys.stderr) + sys.exit(2) + result = json.loads(result_path.read_text()) + + if not result.get("valid"): + print("Submission is not valid — skipping catalog update.") + _set_output("skipped", "true") + sys.exit(0) + + # Load entry + entry_path = Path(args.entry) + if not entry_path.exists(): + print(f"Error: entry file not found: {entry_path}", file=sys.stderr) + sys.exit(2) + new_entry = json.loads(entry_path.read_text()) + + item_id = result["item_id"] + is_update = result.get("is_update", False) + cat_key = CATALOG_KEY[args.type] + + # Update catalog + catalog_path = Path(args.catalog) + with open(catalog_path) as f: + catalog = json.load(f) + + catalog[cat_key][item_id] = new_entry + catalog["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + catalog[cat_key] = dict(sorted(catalog[cat_key].items())) + + with open(catalog_path, "w") as f: + json.dump(catalog, f, indent=2) + f.write("\n") + + print(f"Updated {catalog_path}: {'replaced' if is_update else 'added'} {item_id}") + + # Regenerate docs table if requested + if args.table_target: + table_script = Path(__file__).parent / "catalog-generate-table.py" + subprocess.run( + [ + sys.executable, str(table_script), + "--catalog", args.catalog, + "--type", args.type, + "--target", args.table_target, + ], + check=True, + ) + + # Set outputs for the workflow + action = "update" if is_update else "add" + _set_output("skipped", "false") + _set_output("item_id", item_id) + _set_output("is_update", str(is_update).lower()) + _set_output("action", action.title()) + _set_output("action_verb", f"{action.title()}s") + _set_output("branch", f"community/{action}-{args.type}-{item_id}") + + +def _set_output(name: str, value: str) -> None: + """Write a GitHub Actions output variable.""" + gh_output = os.environ.get("GITHUB_OUTPUT") + if gh_output: + with open(gh_output, "a") as f: + f.write(f"{name}={value}\n") + # Also print for local debugging + print(f" output: {name}={value}") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py new file mode 100644 index 0000000000..9b64e97d98 --- /dev/null +++ b/.github/scripts/catalog-validate.py @@ -0,0 +1,1025 @@ +#!/usr/bin/env python3 +"""Validate a community catalog submission from a GitHub issue form. + +Parses the structured markdown body produced by GitHub issue forms, +validates all fields, and optionally generates the catalog JSON entry. + +Usage (typically called from GitHub Actions): + python .github/scripts/catalog-validate.py \ + --catalog extensions/catalog.community.json \ + --type extension + +Environment variables: + ISSUE_BODY — The issue body markdown (required) + ISSUE_NUMBER — The issue number (optional, for reporting) + GITHUB_TOKEN — Token for authenticated URL checks (optional) + GITHUB_OUTPUT — Path to GitHub Actions output file (optional) +""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import re +import socket +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +# --------------------------------------------------------------------------- +# Issue body parser +# --------------------------------------------------------------------------- + +def parse_issue_body(body: str, known_labels: set[str] | None = None) -> dict[str, str]: + """Parse a GitHub issue form body into {label: value} pairs. + + GitHub issue forms render as markdown with ``### Label`` headers + followed by the user's input. Checkbox groups render as lists of + ``- [X]`` / ``- [ ]`` items. + + When *known_labels* is provided, only ``### Label`` lines whose text + matches a known label start a new field. Other ``###`` headings + inside textarea content are preserved as-is. + """ + fields: dict[str, str] = {} + current_label: str | None = None + current_lines: list[str] = [] + + for line in body.splitlines(): + if line.startswith("### "): + heading = line[4:].strip() + # Only split on known form labels (if provided) + if known_labels is None or heading in known_labels: + # Store previous field + if current_label is not None: + fields[current_label] = "\n".join(current_lines).strip() + current_label = heading + current_lines = [] + continue + current_lines.append(line) + + # Don't forget the last field + if current_label is not None: + fields[current_label] = "\n".join(current_lines).strip() + + return fields + + +# Mapping from issue form labels to internal field keys — one per catalog type +EXTENSION_LABEL_TO_KEY: dict[str, str] = { + "Extension ID": "item_id", + "Extension Name": "item_name", + "Version": "version", + "Description": "description", + "Author": "author", + "Repository URL": "repository", + "Download URL": "download_url", + "License": "license", + "Homepage (optional)": "homepage", + "Documentation URL (optional)": "documentation", + "Changelog URL (optional)": "changelog", + "Required Spec Kit Version": "speckit_version", + "Required Tools (optional)": "required_tools", + "Number of Commands": "commands_count", + "Number of Hooks (optional)": "hooks_count", + "Tags": "tags", + "Key Features": "features", + "Testing Checklist": "testing_checklist", + "Submission Requirements": "requirements_checklist", + "Testing Details": "testing_details", + "Example Usage": "example_usage", + "Proposed Catalog Entry": "catalog_entry", + "Additional Context": "additional_context", +} + +PRESET_LABEL_TO_KEY: dict[str, str] = { + "Preset ID": "item_id", + "Preset Name": "item_name", + "Version": "version", + "Description": "description", + "Author": "author", + "Repository URL": "repository", + "Download URL": "download_url", + "License": "license", + "Required Spec Kit Version": "speckit_version", + "Required Extensions (optional)": "required_extensions", + "Templates Provided": "templates_provided", + "Commands Provided (optional)": "commands_provided", + "Number of Scripts (optional)": "scripts_count", + "Tags": "tags", + "Key Features": "features", + "Testing Checklist": "testing_checklist", + "Submission Requirements": "requirements_checklist", + "Additional Context": "additional_context", +} + +LABEL_MAPS: dict[str, dict[str, str]] = { + "extension": EXTENSION_LABEL_TO_KEY, + "preset": PRESET_LABEL_TO_KEY, +} + +# Catalog JSON top-level key per type +CATALOG_KEY: dict[str, str] = { + "extension": "extensions", + "preset": "presets", +} + + +def normalize_fields(raw: dict[str, str], catalog_type: str) -> dict[str, str]: + """Map label-keyed fields to internal keys.""" + label_map = LABEL_MAPS[catalog_type] + result: dict[str, str] = {} + for label, value in raw.items(): + key = label_map.get(label) + if key: + result[key] = value + return result + + +# --------------------------------------------------------------------------- +# Validators +# --------------------------------------------------------------------------- + +# Each validator returns (ok, message). *ok* is True on pass. + +_ID_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +_SEMVER_RE = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(-((0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + r"(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + r"(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$" +) +_VERSION_CONSTRAINT_RE = re.compile(r"^[><=!~]+\d") +_URL_RE = re.compile(r"^https?://\S+$") + + +def _present(value: str | None) -> bool: + return bool(value and value.strip() and value.strip() != "_No response_") + + +def _parse_semver(version: str): + """Parse a version string into a comparable object. + + Returns a ``packaging.version.Version`` when the library is available, + otherwise a tuple of ints (major, minor, patch). + """ + try: + from packaging.version import Version + return Version(version) + except (ImportError, Exception): + pass + # Fallback: strip pre-release/build metadata for comparison + base = version.split("-")[0].split("+")[0] + return tuple(int(p) for p in base.split(".")) + + +def validate_item_id( + value: str, catalog: dict, catalog_type: str, +) -> tuple[bool, str, bool]: + """Validate item ID. Returns (ok, message, is_update).""" + label = catalog_type.title() # "Extension" or "Preset" + cat_key = CATALOG_KEY[catalog_type] + if not _present(value): + return False, f"{label} ID is required.", False + value = value.strip() + if not _ID_RE.match(value): + return False, ( + f"{label} ID `{value}` is invalid. " + f"Use only lowercase letters, digits, and hyphens (e.g., `my-{catalog_type}`)." + ), False + if value in catalog.get(cat_key, {}): + existing = catalog[cat_key][value] + return True, ( + f"{label} ID `{value}` already exists (v{existing.get('version', '?')}). " + "This will be processed as an **update**." + ), True + return True, f"{label} ID `{value}` is valid and available (new submission).", False + + +def validate_version( + value: str, *, is_update: bool = False, catalog: dict | None = None, + item_id: str = "", catalog_type: str = "extension", +) -> tuple[bool, str]: + if not _present(value): + return False, "Version is required." + value = value.strip() + if not _SEMVER_RE.match(value): + return False, ( + f"Version `{value}` is not valid semver. " + "Use the format `X.Y.Z` (e.g., `1.0.0`)." + ) + if is_update and catalog and item_id: + cat_key = CATALOG_KEY[catalog_type] + existing = catalog.get(cat_key, {}).get(item_id, {}) + old_version = existing.get("version", "0.0.0") + try: + if _parse_semver(value) <= _parse_semver(old_version): + return False, ( + f"Version `{value}` must be higher than the existing " + f"version `{old_version}`." + ) + except (ValueError, TypeError): + pass # If existing version is unparseable, skip comparison + return True, f"Version `{value}` is valid (upgrade from `{old_version}`)." + return True, f"Version `{value}` is valid." + + +def validate_description(value: str, *, is_update: bool = False) -> tuple[bool, str]: + if not _present(value): + return False, "Description is required." + value = value.strip() + if len(value) > 200: + if is_update: + return True, ( + f"Description is {len(value)} characters (over 200). " + "Consider shortening it in a future update." + ) + return False, ( + f"Description is {len(value)} characters — please keep it under 200." + ) + return True, "Description is valid." + + +def validate_url( + value: str, field_name: str, *, required: bool = True, +) -> tuple[bool, str]: + if not _present(value): + if required: + return False, f"{field_name} is required." + return True, f"{field_name} not provided (optional)." + value = value.strip() + if not _URL_RE.match(value): + return False, f"{field_name} `{value}` is not a valid URL." + return True, f"{field_name} URL format is valid." + + +def _is_safe_redirect_target(url: str) -> bool: + """Return True if *url* does not point to a private/reserved address.""" + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + return False + hostname = parsed.hostname + if not hostname: + return False + try: + for _family, _type, _proto, _canonname, sockaddr in socket.getaddrinfo(hostname, None): + ip = ipaddress.ip_address(sockaddr[0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified or ip.is_multicast: + return False + except (socket.gaierror, ValueError): + return False # fail closed: unresolvable targets are blocked + return True + + +class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler): + """Redirect handler that blocks redirects to private/reserved IPs.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + if not _is_safe_redirect_target(newurl): + raise urllib.error.URLError( + f"Redirect to private/reserved address blocked: {newurl}" + ) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def check_url_reachable( + url: str, field_name: str, token: str | None = None, +) -> tuple[bool, str]: + """HTTP HEAD check. Returns (ok, message).""" + if not _present(url): + return True, "" # skip if empty/optional + url = url.strip() + + # --- SSRF guard: reject non-HTTP(S) schemes, private/loopback IPs --- + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + return False, f"{field_name} URL must use http or https scheme." + hostname = parsed.hostname + if not hostname: + return False, f"{field_name} URL has no hostname." + + # Restrict to known hosts to mitigate DNS-rebinding TOCTOU risks + _allowed_hosts = { + "github.com", "www.github.com", "codeload.github.com", + "raw.githubusercontent.com", "objects.githubusercontent.com", + } + if hostname not in _allowed_hosts: + return False, ( + f"{field_name} URL must be on a GitHub domain " + f"(got `{hostname}`)." + ) + try: + addr_info = socket.getaddrinfo(hostname, None) + for _family, _type, _proto, _canonname, sockaddr in addr_info: + ip = ipaddress.ip_address(sockaddr[0]) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified or ip.is_multicast: + return False, ( + f"{field_name} URL `{url}` resolves to a private/reserved address." + ) + except (socket.gaierror, ValueError): + return False, ( + f"{field_name} URL `{url}` could not be resolved." + ) + + _gh_hosts = {"github.com", "www.github.com", "codeload.github.com", "raw.githubusercontent.com"} + _is_github = hostname in _gh_hosts + + # Build an opener that validates redirect targets against SSRF checks + opener = urllib.request.build_opener(_SafeRedirectHandler) + + req = urllib.request.Request(url, method="HEAD") + req.add_header("User-Agent", "spec-kit-catalog-validator/1.0") + if token and _is_github: + req.add_header("Authorization", f"token {token}") + try: + with opener.open(req, timeout=15) as resp: + if resp.status < 400: + return True, f"{field_name} URL is reachable." + except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc: + # Try GET as fallback — some servers reject HEAD + req2 = urllib.request.Request(url, method="GET") + req2.add_header("User-Agent", "spec-kit-catalog-validator/1.0") + if token and _is_github: + req2.add_header("Authorization", f"token {token}") + try: + with opener.open(req2, timeout=15) as resp2: + if resp2.status < 400: + return True, f"{field_name} URL is reachable." + except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc2: + return False, ( + f"{field_name} URL `{url}` is not reachable: {exc2}" + ) + return False, f"{field_name} URL `{url}` returned an error." + + +def validate_speckit_version(value: str) -> tuple[bool, str]: + if not _present(value): + return False, "Required Spec Kit Version is required." + value = value.strip() + try: + from packaging.specifiers import InvalidSpecifier, SpecifierSet + SpecifierSet(value) + except InvalidSpecifier as exc: + return False, ( + f"Spec Kit version constraint `{value}` is invalid: {exc}. " + "Use a PEP 440 constraint like `>=0.6.0`." + ) + except ImportError: + # Fallback if packaging is not available + if not _VERSION_CONSTRAINT_RE.match(value): + return False, ( + f"Spec Kit version constraint `{value}` looks invalid. " + "Use a PEP 440 constraint like `>=0.6.0`." + ) + return True, f"Version constraint `{value}` is valid." + + +def validate_commands_count(value: str) -> tuple[bool, str]: + if not _present(value): + return False, "Number of Commands is required." + value = value.strip() + if not value.isdigit() or int(value) < 1: + return False, ( + f"Number of Commands `{value}` must be a positive integer." + ) + return True, f"Commands count: {value}." + + +def validate_hooks_count(value: str) -> tuple[bool, str]: + if not _present(value): + return True, "Hooks count not provided (defaults to 0)." + value = value.strip() + if not value.isdigit(): + return False, f"Number of Hooks `{value}` must be a non-negative integer." + return True, f"Hooks count: {value}." + + +def validate_tags(value: str) -> tuple[bool, str]: + if not _present(value): + return False, "Tags are required." + raw_tags = list(dict.fromkeys( + t.strip().lower() for t in value.split(",") if t.strip() + )) # dedupe preserving order + dupes_removed = len([t.strip().lower() for t in value.split(",") if t.strip()]) - len(raw_tags) + if len(raw_tags) < 2: + return False, "Please provide at least 2 unique tags." + if len(raw_tags) > 10: + return False, f"Too many tags ({len(raw_tags)} unique). Please provide 2-10 tags." + bad = [t for t in raw_tags if not re.match(r"^[a-z0-9-]+$", t)] + if bad: + return False, ( + f"Tags must be lowercase alphanumeric with hyphens: {', '.join(bad)}" + ) + msg = f"Tags: {', '.join(raw_tags)}." + if dupes_removed: + msg += f" ({dupes_removed} duplicate(s) removed.)" + return True, msg + + +def validate_license(value: str) -> tuple[bool, str]: + if not _present(value): + return False, "License is required." + return True, f"License: {value.strip()}." + + +def validate_required_text(value: str, field_name: str) -> tuple[bool, str]: + if not _present(value): + return False, f"{field_name} is required." + return True, f"{field_name} provided." + + +def validate_checklist(value: str, field_name: str) -> tuple[bool, str]: + """Check that all checkboxes are ticked.""" + if not _present(value): + return False, f"{field_name} is required." + lines = [l.strip() for l in value.splitlines() if l.strip().startswith("- [")] + if not lines: + return False, f"{field_name} must contain at least one checkbox item." + unchecked = [l for l in lines if l.startswith("- [ ]")] + if unchecked: + items = "\n".join(f" - {l[5:].strip()}" for l in unchecked) + return False, ( + f"The following {field_name} items are not checked:\n{items}" + ) + return True, f"All {field_name} items confirmed." + + +def _count_list_items(value: str) -> int: + """Count items in a textarea value (bullets or non-empty lines).""" + if not _present(value): + return 0 + lines = [line.strip() for line in value.splitlines() if line.strip()] + # If any lines use bullet format, count only those + bullets = [l for l in lines if l.startswith(("- ", "* "))] + if bullets: + return len(bullets) + # Otherwise count all non-empty lines + return len(lines) + + +# --------------------------------------------------------------------------- +# Full validation pipeline +# --------------------------------------------------------------------------- + +def validate_submission( + fields: dict[str, str], + catalog: dict, + catalog_type: str, + *, + check_urls: bool = True, + github_token: str | None = None, +) -> tuple[bool, list[dict], bool]: + """Run all validators. Returns (all_passed, results_list, is_update).""" + results: list[dict] = [] + label = catalog_type.title() # "Extension" or "Preset" + + def _add(field: str, ok: bool, msg: str, *, severity: str = "error") -> None: + results.append({ + "field": field, + "ok": ok, + "message": msg, + "severity": "info" if ok else severity, + }) + + # --- ID --- + ok, msg, is_update = validate_item_id( + fields.get("item_id", ""), catalog, catalog_type, + ) + _add(f"{label} ID", ok, msg) + + # --- Name --- + ok, msg = validate_required_text(fields.get("item_name", ""), f"{label} Name") + _add(f"{label} Name", ok, msg) + + # --- Version --- + item_id = fields.get("item_id", "").strip() + ok, msg = validate_version( + fields.get("version", ""), + is_update=is_update, + catalog=catalog, + item_id=item_id, + catalog_type=catalog_type, + ) + _add("Version", ok, msg) + + # --- Common fields --- + ok, msg = validate_description(fields.get("description", ""), is_update=is_update) + _add("Description", ok, msg) + + ok, msg = validate_required_text(fields.get("author", ""), "Author") + _add("Author", ok, msg) + + ok, msg = validate_license(fields.get("license", "")) + _add("License", ok, msg) + + # --- URLs (common) --- + for url_field, url_label, required in [ + ("repository", "Repository URL", True), + ("download_url", "Download URL", True), + ]: + ok, msg = validate_url(fields.get(url_field, ""), url_label, required=required) + _add(url_label, ok, msg) + + # --- Extension-only URLs --- + if catalog_type == "extension": + for url_field, url_label in [ + ("homepage", "Homepage"), + ("documentation", "Documentation URL"), + ("changelog", "Changelog URL"), + ]: + ok, msg = validate_url( + fields.get(url_field, ""), url_label, required=False, + ) + _add(url_label, ok, msg) + + # --- URL reachability --- + if check_urls: + for url_field, url_label in [ + ("repository", "Repository URL"), + ("download_url", "Download URL"), + ]: + val = fields.get(url_field, "").strip() + if val and _URL_RE.match(val): + ok, msg = check_url_reachable(val, url_label, github_token) + _add(f"{url_label} (reachable)", ok, msg) + + # --- Spec Kit version --- + ok, msg = validate_speckit_version(fields.get("speckit_version", "")) + _add("Required Spec Kit Version", ok, msg) + + # --- Type-specific provides --- + if catalog_type == "extension": + ok, msg = validate_commands_count(fields.get("commands_count", "")) + _add("Number of Commands", ok, msg) + + ok, msg = validate_hooks_count(fields.get("hooks_count", "")) + _add("Number of Hooks", ok, msg) + elif catalog_type == "preset": + templates_provided = fields.get("templates_provided", "").strip() + commands_provided = fields.get("commands_provided", "").strip() + if templates_provided or commands_provided: + _add("Preset Provides", True, "Templates and/or commands provided.") + else: + _add("Preset Provides", False, + "At least one of Templates Provided or Commands Provided is required.") + # Commands Provided is optional for presets + # Validate scripts count if provided + scripts_val = fields.get("scripts_count", "").strip() + if scripts_val: + if not scripts_val.isdigit(): + _add("Number of Scripts", False, + f"Number of Scripts `{scripts_val}` must be a non-negative integer.") + else: + _add("Number of Scripts", True, f"Scripts count: {scripts_val}.") + + # --- Tags --- + ok, msg = validate_tags(fields.get("tags", "")) + _add("Tags", ok, msg) + + # --- Text fields --- + ok, msg = validate_required_text(fields.get("features", ""), "Key Features") + _add("Key Features", ok, msg) + + if catalog_type == "extension": + ok, msg = validate_required_text( + fields.get("testing_details", ""), "Testing Details", + ) + _add("Testing Details", ok, msg) + + ok, msg = validate_required_text( + fields.get("example_usage", ""), "Example Usage", + ) + _add("Example Usage", ok, msg) + + # --- Checklists --- + ok, msg = validate_checklist( + fields.get("testing_checklist", ""), "Testing Checklist", + ) + _add("Testing Checklist", ok, msg) + + ok, msg = validate_checklist( + fields.get("requirements_checklist", ""), "Submission Requirements", + ) + _add("Submission Requirements", ok, msg) + + all_passed = all(r["ok"] for r in results) + return all_passed, results, is_update + + +# --------------------------------------------------------------------------- +# Catalog entry builder +# --------------------------------------------------------------------------- + +def parse_tags(value: str) -> list[str]: + """Parse comma-separated tags into a sorted, deduplicated list.""" + return sorted(set( + t.strip().lower() + for t in value.split(",") + if t.strip() + )) + + +def _clean(value: str | None) -> str: + """Return stripped value, or empty string if absent / GitHub placeholder.""" + if not _present(value): + return "" + return value.strip() + + +def build_catalog_entry( + fields: dict[str, str], + catalog_type: str, + catalog: dict | None = None, + is_update: bool = False, +) -> dict: + """Build a catalog.community.json entry from validated fields. + + On updates, preserves ``created_at``, ``downloads``, ``stars``, and + ``verified`` from the existing catalog entry. + """ + if catalog_type == "preset": + return _build_preset_entry(fields, catalog=catalog, is_update=is_update) + return _build_extension_entry(fields, catalog=catalog, is_update=is_update) + + +def _build_extension_entry( + fields: dict[str, str], + catalog: dict | None = None, + is_update: bool = False, +) -> dict: + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + hooks = 0 + if _present(fields.get("hooks_count", "")): + hooks = int(fields["hooks_count"].strip()) + + repo = _clean(fields.get("repository")) + item_id = _clean(fields.get("item_id")) + + existing: dict = {} + if is_update and catalog: + existing = catalog.get("extensions", {}).get(item_id, {}) + + # Build requires dict — always include speckit_version, preserve/add tools + requires: dict = { + "speckit_version": fields["speckit_version"].strip(), + } + tools_raw = _clean(fields.get("required_tools", "")) + if tools_raw: + # Parse markdown bullet list: "- name (>=version) - required/optional" + # Also handles comma-separated "name>=version" as fallback + tools_list = [] + _tool_re = re.compile( + r"^[-*]\s+" # bullet + r"(?P[^\s(]+)" # tool name + r"(?:\s*\((?P[^)]+)\))?" # optional (>=x.y.z) + r"(?:\s*[-–—]\s*(?P\w+))?" # optional - required/optional + ) + for line in tools_raw.splitlines(): + line = line.strip() + if not line: + continue + m = _tool_re.match(line) + if m: + name = m.group("name").strip() + version = m.group("version") + version = version.strip() if version else None + req_str = (m.group("req") or "required").lower() + tool_entry: dict = { + "name": name, + "required": req_str != "optional", + } + if version: + tool_entry["version"] = version + tools_list.append(tool_entry) + else: + # Fallback: comma-separated "name>=version" + for part in line.split(","): + part = part.strip().lstrip("-*").strip() + if not part: + continue + for op in (">=", "<=", "==", "!=", ">", "<"): + if op in part: + n, v = part.split(op, 1) + tools_list.append({ + "name": n.strip(), + "version": op + v.strip(), + "required": True, + }) + break + else: + tools_list.append({ + "name": part, + "required": True, + }) + if tools_list: + requires["tools"] = tools_list + elif is_update and "tools" in existing.get("requires", {}): + requires["tools"] = existing["requires"]["tools"] + + return { + "name": _clean(fields.get("item_name")), + "id": item_id, + "description": _clean(fields.get("description")), + "author": _clean(fields.get("author")), + "version": _clean(fields.get("version")), + "download_url": _clean(fields.get("download_url")), + "repository": repo, + "homepage": _clean(fields.get("homepage")) or repo, + "documentation": ( + _clean(fields.get("documentation")) + or repo + "/blob/main/README.md" + ), + "changelog": ( + _clean(fields.get("changelog")) + or repo + "/blob/main/CHANGELOG.md" + ), + "license": fields["license"].strip(), + "requires": requires, + "provides": { + "commands": int(fields["commands_count"].strip()), + "hooks": hooks, + }, + "tags": parse_tags(fields["tags"]), + "verified": existing.get("verified", False), + "downloads": existing.get("downloads", 0), + "stars": existing.get("stars", 0), + "created_at": existing.get("created_at", now), + "updated_at": now, + } + + +def _build_preset_entry( + fields: dict[str, str], + catalog: dict | None = None, + is_update: bool = False, +) -> dict: + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + repo = _clean(fields.get("repository")) + item_id = _clean(fields.get("item_id")) + + templates_count = _count_list_items(fields.get("templates_provided", "")) + commands_count = _count_list_items(fields.get("commands_provided", "")) + + existing: dict = {} + if is_update and catalog: + existing = catalog.get("presets", {}).get(item_id, {}) + + # Build requires — include extensions from form or preserve on updates + requires: dict = { + "speckit_version": fields["speckit_version"].strip(), + } + extensions_raw = _clean(fields.get("required_extensions", "")) + if extensions_raw: + # Parse comma-separated or bullet-list extension IDs + ext_list = [] + for line in extensions_raw.splitlines(): + line = line.strip().lstrip("-*").strip() + for part in line.split(","): + part = part.strip().lower() + if part and _ID_RE.match(part): + ext_list.append(part) + # Deduplicate and sort for stable catalog output + ext_list = sorted(set(ext_list)) + if ext_list: + requires["extensions"] = ext_list + elif is_update and "extensions" in existing.get("requires", {}): + requires["extensions"] = existing["requires"]["extensions"] + + # Documentation URL: preserve on update, fall back to repo/blob/main/README.md + documentation = "" + if is_update: + documentation = existing.get("documentation", "") + if not documentation: + documentation = repo + "/blob/main/README.md" + + # Scripts count from form or preserve on updates + scripts_count = 0 + scripts_raw = _clean(fields.get("scripts_count", "")) + if scripts_raw and scripts_raw.isdigit(): + scripts_count = int(scripts_raw) + elif is_update: + scripts_count = existing.get("provides", {}).get("scripts", 0) + + provides: dict = { + "templates": templates_count, + "commands": commands_count, + } + if scripts_count: + provides["scripts"] = scripts_count + + return { + "name": _clean(fields.get("item_name")), + "id": item_id, + "version": _clean(fields.get("version")), + "description": _clean(fields.get("description")), + "author": _clean(fields.get("author")), + "repository": repo, + "download_url": _clean(fields.get("download_url")), + "homepage": repo, + "documentation": documentation, + "license": fields["license"].strip(), + "requires": requires, + "provides": provides, + "tags": parse_tags(fields["tags"]), + "created_at": existing.get("created_at", now), + "updated_at": now, + } + + +# --------------------------------------------------------------------------- +# Report formatter +# --------------------------------------------------------------------------- + +def format_report( + all_passed: bool, + results: list[dict], + entry: dict | None, + issue_number: str | None, + is_update: bool = False, + catalog_type: str = "extension", +) -> str: + """Build a markdown validation report for the issue comment.""" + lines: list[str] = [] + action = "update" if is_update else "add" + label = catalog_type + + if all_passed: + lines.append("## :white_check_mark: Submission Validated") + lines.append("") + lines.append( + f"All checks passed! A pull request will be created automatically " + f"to {action} this {label} in the community catalog." + ) + else: + lines.append("## :x: Submission Needs Changes") + lines.append("") + lines.append( + "Some checks did not pass. Please edit this issue to fix " + "the items below, and validation will re-run automatically." + ) + + lines.append("") + lines.append("### Validation Results") + lines.append("") + lines.append("| Check | Status | Details |") + lines.append("|-------|--------|---------|") + + for r in results: + icon = ":white_check_mark:" if r["ok"] else ":x:" + # Escape pipe characters in messages + msg = r["message"].replace("|", "\\|").replace("\n", " ") + lines.append(f"| {r['field']} | {icon} | {msg} |") + + if all_passed and entry: + lines.append("") + lines.append("### Generated Catalog Entry") + lines.append("") + lines.append("
") + lines.append("Click to expand JSON") + lines.append("") + lines.append("```json") + lines.append(json.dumps({entry["id"]: entry}, indent=2)) + lines.append("```") + lines.append("") + lines.append("
") + + lines.append("") + lines.append("---") + lines.append( + "*This comment was generated automatically by the catalog submission workflow.*" + ) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--catalog", + required=True, + help="Path to the catalog JSON file (e.g., extensions/catalog.community.json)", + ) + parser.add_argument( + "--type", + choices=["extension", "preset"], + default="extension", + help="Catalog type", + ) + parser.add_argument( + "--skip-url-checks", + action="store_true", + help="Skip HTTP reachability checks for URLs", + ) + parser.add_argument( + "--output-report", + default="/tmp/validation-report.md", + help="Path to write the markdown report", + ) + parser.add_argument( + "--output-entry", + default="/tmp/catalog-entry.json", + help="Path to write the generated catalog entry", + ) + parser.add_argument( + "--output-result", + default="/tmp/validation-result.json", + help="Path to write the validation result metadata", + ) + args = parser.parse_args() + + # Read inputs + issue_body = os.environ.get("ISSUE_BODY", "") + if not issue_body: + print("Error: ISSUE_BODY environment variable is empty.", file=sys.stderr) + sys.exit(2) + + issue_number = os.environ.get("ISSUE_NUMBER") + github_token = os.environ.get("GITHUB_TOKEN") + + # Load catalog + catalog_path = Path(args.catalog) + if not catalog_path.exists(): + print(f"Error: Catalog file not found: {catalog_path}", file=sys.stderr) + sys.exit(2) + + with open(catalog_path) as f: + catalog = json.load(f) + + # Parse and normalize + known_labels = set(LABEL_MAPS[args.type].keys()) + raw_fields = parse_issue_body(issue_body, known_labels=known_labels) + fields = normalize_fields(raw_fields, args.type) + + if not fields: + print("Error: Could not parse any fields from the issue body.", file=sys.stderr) + sys.exit(2) + + # Validate + all_passed, results, is_update = validate_submission( + fields, + catalog, + args.type, + check_urls=not args.skip_url_checks, + github_token=github_token, + ) + + # Build entry (even on failure, for debugging) + entry = None + try: + entry = build_catalog_entry( + fields, args.type, catalog=catalog, is_update=is_update, + ) + except (KeyError, ValueError): + pass # Entry can't be built if required fields are missing + + # Write report + report = format_report( + all_passed, results, entry, issue_number, + is_update=is_update, catalog_type=args.type, + ) + Path(args.output_report).write_text(report) + + # Write entry + if entry: + Path(args.output_entry).write_text(json.dumps(entry, indent=2)) + + # Write result metadata + item_id = fields.get("item_id", "").strip() + result_meta = { + "valid": all_passed, + "item_id": item_id, + "catalog_type": args.type, + "is_update": is_update, + "error_count": sum(1 for r in results if not r["ok"]), + "check_count": len(results), + } + Path(args.output_result).write_text(json.dumps(result_meta, indent=2)) + + # Set GitHub Actions outputs if available + gh_output = os.environ.get("GITHUB_OUTPUT") + if gh_output: + with open(gh_output, "a") as f: + f.write(f"valid={str(all_passed).lower()}\n") + f.write(f"item_id={item_id}\n") + f.write(f"catalog_type={args.type}\n") + + print( + f"Validation {'PASSED' if all_passed else 'FAILED'}: " + f"{sum(1 for r in results if r['ok'])}/{len(results)} checks passed.", + ) + # Always exit 0 so the workflow can post the comment + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/catalog-pr.yml b/.github/workflows/catalog-pr.yml new file mode 100644 index 0000000000..26b7afc446 --- /dev/null +++ b/.github/workflows/catalog-pr.yml @@ -0,0 +1,240 @@ +name: "Catalog: Create PR" + +on: + issues: + types: [labeled] + +concurrency: + group: catalog-pr-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + create-extension-pr: + if: > + github.event.label.name == 'validated' && + contains(github.event.issue.labels.*.name, 'extension-submission') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.RELEASE_PAT }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Parse and build entry + env: + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python .github/scripts/catalog-validate.py \ + --catalog extensions/catalog.community.json \ + --type extension + + - name: Update catalog + id: update + run: | + python .github/scripts/catalog-pr.py \ + --catalog extensions/catalog.community.json \ + --type extension + + - name: Create or update pull request + if: steps.update.outputs.skipped != 'true' + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + ITEM_ID: ${{ steps.update.outputs.item_id }} + ACTION: ${{ steps.update.outputs.action }} + ACTION_VERB: ${{ steps.update.outputs.action_verb }} + BRANCH: ${{ steps.update.outputs.branch }} + run: | + set -euo pipefail + + # Check if branch already exists (from a previous run) + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + git fetch origin "$BRANCH" + git checkout "$BRANCH" + git reset --hard origin/main + # Re-run the catalog update on the fresh branch + python .github/scripts/catalog-pr.py \ + --catalog extensions/catalog.community.json \ + --type extension + else + git checkout -b "$BRANCH" + fi + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add extensions/catalog.community.json + git commit -m "${ACTION} community extension: ${ITEM_ID} + + Automated from issue #${ISSUE_NUMBER}. + + Co-authored-by: ${ISSUE_AUTHOR} <${ISSUE_AUTHOR}@users.noreply.github.com>" + + git push -u origin "$BRANCH" --force-with-lease + + # Create or update PR + EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty') + + PR_BODY="## Community Extension Submission + + ${ACTION_VERB} **${ITEM_ID}** in the community extension catalog. + + **Submitted by:** @${ISSUE_AUTHOR} + **Source issue:** #${ISSUE_NUMBER} + + ### What this PR does + + - ${ACTION_VERB} the entry in \`extensions/catalog.community.json\` + - All submission fields have been validated automatically + + ### Reviewer checklist + + - [ ] Entry looks correct in the catalog JSON + - [ ] Extension repository is accessible and contains valid code + - [ ] No concerns with the extension content + + --- + *This PR was generated automatically from the issue submission. Approve and merge to publish.*" + + if [ -n "$EXISTING_PR" ]; then + gh pr edit "$EXISTING_PR" \ + --title "${ACTION} community extension: ${ITEM_ID}" \ + --body "$PR_BODY" + else + gh pr create \ + --title "${ACTION} community extension: ${ITEM_ID}" \ + --body "$PR_BODY" \ + --head "$BRANCH" \ + --base main + + NEW_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') + gh issue comment "$ISSUE_NUMBER" \ + --body "Pull request #${NEW_PR} has been created for this submission. A maintainer will review it shortly." + fi + + create-preset-pr: + if: > + github.event.label.name == 'validated' && + contains(github.event.issue.labels.*.name, 'preset-submission') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.RELEASE_PAT }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Parse and build entry + env: + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python .github/scripts/catalog-validate.py \ + --catalog presets/catalog.community.json \ + --type preset + + - name: Update catalog and regenerate table + id: update + run: | + python .github/scripts/catalog-pr.py \ + --catalog presets/catalog.community.json \ + --type preset \ + --table-target docs/community/presets.md + + - name: Create or update pull request + if: steps.update.outputs.skipped != 'true' + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + ITEM_ID: ${{ steps.update.outputs.item_id }} + ACTION: ${{ steps.update.outputs.action }} + ACTION_VERB: ${{ steps.update.outputs.action_verb }} + BRANCH: ${{ steps.update.outputs.branch }} + run: | + set -euo pipefail + + # Check if branch already exists (from a previous run) + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + git fetch origin "$BRANCH" + git checkout "$BRANCH" + git reset --hard origin/main + # Re-run on the fresh branch + python .github/scripts/catalog-pr.py \ + --catalog presets/catalog.community.json \ + --type preset \ + --table-target docs/community/presets.md + else + git checkout -b "$BRANCH" + fi + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add presets/catalog.community.json docs/community/presets.md + git commit -m "${ACTION} community preset: ${ITEM_ID} + + Automated from issue #${ISSUE_NUMBER}. + + Co-authored-by: ${ISSUE_AUTHOR} <${ISSUE_AUTHOR}@users.noreply.github.com>" + + git push -u origin "$BRANCH" --force-with-lease + + EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty') + + PR_BODY="## Community Preset Submission + + ${ACTION_VERB} **${ITEM_ID}** in the community preset catalog. + + **Submitted by:** @${ISSUE_AUTHOR} + **Source issue:** #${ISSUE_NUMBER} + + ### What this PR does + + - ${ACTION_VERB} the entry in \`presets/catalog.community.json\` + - Auto-regenerates the table in \`docs/community/presets.md\` + - All submission fields have been validated automatically + + ### Reviewer checklist + + - [ ] Entry looks correct in the catalog JSON + - [ ] Preset repository is accessible and contains valid code + - [ ] No concerns with the preset content + + --- + *This PR was generated automatically from the issue submission. Approve and merge to publish.*" + + if [ -n "$EXISTING_PR" ]; then + gh pr edit "$EXISTING_PR" \ + --title "${ACTION} community preset: ${ITEM_ID}" \ + --body "$PR_BODY" + else + gh pr create \ + --title "${ACTION} community preset: ${ITEM_ID}" \ + --body "$PR_BODY" \ + --head "$BRANCH" \ + --base main + + NEW_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') + gh issue comment "$ISSUE_NUMBER" \ + --body "Pull request #${NEW_PR} has been created for this submission. A maintainer will review it shortly." + fi diff --git a/.github/workflows/catalog-validate.yml b/.github/workflows/catalog-validate.yml new file mode 100644 index 0000000000..75459d0f07 --- /dev/null +++ b/.github/workflows/catalog-validate.yml @@ -0,0 +1,249 @@ +name: "Catalog: Validate Submission" + +on: + issues: + types: [opened, edited] + +concurrency: + group: catalog-validate-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + validate-extension: + if: contains(github.event.issue.labels.*.name, 'extension-submission') + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install packaging + + - name: Validate submission + id: validate + env: + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python .github/scripts/catalog-validate.py \ + --catalog extensions/catalog.community.json \ + --type extension + + - name: Post validation comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = fs.readFileSync('/tmp/validation-report.md', 'utf8'); + + // Find existing bot comment to update (avoid spam) + const allComments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + } + ); + const marker = ''; + const botComment = allComments.find(c => + c.body && c.body.includes(marker) + ); + + const body = marker + '\n\n' + report; + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } + + - name: Update labels + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.RELEASE_PAT }} + script: | + const fs = require('fs'); + const result = JSON.parse(fs.readFileSync('/tmp/validation-result.json', 'utf8')); + + // Update labels — uses RELEASE_PAT so the label event + // triggers the catalog-pr workflow + const currentLabels = context.payload.issue.labels.map(l => l.name); + + if (result.valid) { + if (currentLabels.includes('needs-changes')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'needs-changes', + }); + } + if (currentLabels.includes('validated')) { + // Remove + re-add to retrigger catalog-pr on edits + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'validated', + }); + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['validated'], + }); + } else { + if (currentLabels.includes('validated')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'validated', + }); + } + if (!currentLabels.includes('needs-changes')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['needs-changes'], + }); + } + } + + validate-preset: + if: contains(github.event.issue.labels.*.name, 'preset-submission') + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install packaging + + - name: Validate submission + id: validate + env: + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python .github/scripts/catalog-validate.py \ + --catalog presets/catalog.community.json \ + --type preset + + - name: Post validation comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = fs.readFileSync('/tmp/validation-report.md', 'utf8'); + + const allComments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + } + ); + const marker = ''; + const botComment = allComments.find(c => + c.body && c.body.includes(marker) + ); + + const body = marker + '\n\n' + report; + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } + + - name: Update labels + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.RELEASE_PAT }} + script: | + const fs = require('fs'); + const result = JSON.parse(fs.readFileSync('/tmp/validation-result.json', 'utf8')); + + const currentLabels = context.payload.issue.labels.map(l => l.name); + + if (result.valid) { + if (currentLabels.includes('needs-changes')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'needs-changes', + }); + } + if (currentLabels.includes('validated')) { + // Remove + re-add to retrigger catalog-pr on edits + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'validated', + }); + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['validated'], + }); + } else { + if (currentLabels.includes('validated')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'validated', + }); + } + if (!currentLabels.includes('needs-changes')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['needs-changes'], + }); + } + } diff --git a/docs/community/presets.md b/docs/community/presets.md index 03ac777b80..9bf958b677 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -5,6 +5,7 @@ The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/presets/catalog.community.json): + | Preset | Purpose | Provides | Requires | URL | |--------|---------|----------|----------|-----| | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | @@ -18,5 +19,6 @@ The following community-contributed presets customize how Spec Kit behaves — o | Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | + To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md). diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index dfc1125228..2962457deb 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -526,18 +526,7 @@ specify extension add --from https://github.com/.../spec-kit-my ### Option 3: Community Reference Catalog -Submit to the community catalog for public discovery: - -1. **Fork** spec-kit repository -2. **Add entry** to `extensions/catalog.community.json` -3. **Update** the Community Extensions table in `README.md` with your extension -4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) -5. **After merge**, your extension becomes available: - - Users can browse `catalog.community.json` to discover your extension - - Users copy the entry to their own `catalog.json` - - Users install with: `specify extension add my-ext` (from their catalog) - -See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed submission instructions. +Submit to the community catalog for public discovery. See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for instructions. --- @@ -575,6 +564,14 @@ See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed - **Test with multiple versions**: Ensure compatibility - **Graceful degradation**: Handle missing features +### Maintenance + +- **Respond to issues**: Address issues in a timely manner +- **Keep dependencies updated**: Regularly check for updates +- **Maintain a changelog**: Document changes in CHANGELOG.md +- **Deprecation notices**: Give advance notice for breaking changes +- **Use a permissive license**: MIT or Apache 2.0 recommended + --- ## Example Extensions diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index 1433738743..3ff4e281d6 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -1,133 +1,30 @@ # Extension Publishing Guide -This guide explains how to publish your extension to the Spec Kit extension catalog, making it discoverable by `specify extension search`. +This guide explains how to publish your extension to the Spec Kit community catalog, making it discoverable by `specify extension search`. + +For how to develop and test an extension, see the [Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md). ## Table of Contents 1. [Prerequisites](#prerequisites) -2. [Prepare Your Extension](#prepare-your-extension) -3. [Submit to Catalog](#submit-to-catalog) -4. [Verification Process](#verification-process) -5. [Release Workflow](#release-workflow) -6. [Best Practices](#best-practices) +2. [Submit a New Extension to the Community Catalog](#submit-a-new-extension-to-the-community-catalog) +3. [Update Your Extension in the Community Catalog](#update-your-extension-in-the-community-catalog) +4. [FAQ](#faq) --- ## Prerequisites -Before publishing an extension, ensure you have: - -1. **Valid Extension**: A working extension with a valid `extension.yml` manifest -2. **Git Repository**: Extension hosted on GitHub (or other public git hosting) -3. **Documentation**: README.md with installation and usage instructions -4. **License**: Open source license file (MIT, Apache 2.0, etc.) -5. **Versioning**: Semantic versioning (e.g., 1.0.0) -6. **Testing**: Extension tested on real projects - ---- +Before publishing, ensure you have: -## Prepare Your Extension - -### 1. Extension Structure - -Ensure your extension follows the standard structure: - -```text -your-extension/ -├── extension.yml # Required: Extension manifest -├── README.md # Required: Documentation -├── LICENSE # Required: License file -├── CHANGELOG.md # Recommended: Version history -├── .gitignore # Recommended: Git ignore rules -│ -├── commands/ # Extension commands -│ ├── command1.md -│ └── command2.md -│ -├── config-template.yml # Config template (if needed) -│ -└── docs/ # Additional documentation - ├── usage.md - └── examples/ -``` - -### 2. extension.yml Validation - -Verify your manifest is valid: - -```yaml -schema_version: "1.0" - -extension: - id: "your-extension" # Unique lowercase-hyphenated ID - name: "Your Extension Name" # Human-readable name - version: "1.0.0" # Semantic version - description: "Brief description (one sentence)" - author: "Your Name or Organization" - repository: "https://github.com/your-org/spec-kit-your-extension" - license: "MIT" - homepage: "https://github.com/your-org/spec-kit-your-extension" - -requires: - speckit_version: ">=0.1.0" # Required spec-kit version - -provides: - commands: # List all commands - - name: "speckit.your-extension.command" - file: "commands/command.md" - description: "Command description" - -tags: # 2-5 relevant tags - - "category" - - "tool-name" -``` - -**Validation Checklist**: - -- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters) -- ✅ `version` follows semantic versioning (X.Y.Z) -- ✅ `description` is concise (under 100 characters) -- ✅ `repository` URL is valid and public -- ✅ All command files exist in the extension directory -- ✅ Tags are lowercase and descriptive - -### 3. Create GitHub Release - -Create a GitHub release for your extension version: - -```bash -# Tag the release -git tag v1.0.0 -git push origin v1.0.0 - -# Create release on GitHub -# Go to: https://github.com/your-org/spec-kit-your-extension/releases/new -# - Tag: v1.0.0 -# - Title: v1.0.0 - Release Name -# - Description: Changelog/release notes -``` - -The release archive URL will be: - -```text -https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip -``` - -### 4. Test Installation - -Test that users can install from your release: - -```bash -# Test dev installation -specify extension add --dev /path/to/your-extension - -# Test from GitHub archive -specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip -``` +1. **A working extension** — developed and tested per the [Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md) +2. **A GitHub release** — with a release tag for the version you are publishing (the tag is commonly `v1.0.0`; the catalog Version field should be `1.0.0` without the `v` prefix) +3. **A valid download URL** — the release archive URL for your tag +4. **An open-source license** — MIT, Apache 2.0, etc. --- -## Submit to Catalog +## Submit a New Extension to the Community Catalog ### Understanding the Catalogs @@ -135,331 +32,41 @@ Spec Kit uses a dual-catalog system. For details about how catalogs work, see th **For extension publishing**: All community extensions should be added to `catalog.community.json`. Users browse this catalog and copy extensions they trust into their own `catalog.json`. -### 1. Fork the spec-kit Repository - -```bash -# Fork on GitHub -# https://github.com/github/spec-kit/fork - -# Clone your fork -git clone https://github.com/YOUR-USERNAME/spec-kit.git -cd spec-kit -``` - -### 2. Add Extension to Community Catalog - -Edit `extensions/catalog.community.json` and add your extension: - -```json -{ - "schema_version": "1.0", - "updated_at": "2026-01-28T15:54:00Z", - "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", - "extensions": { - "your-extension": { - "name": "Your Extension Name", - "id": "your-extension", - "description": "Brief description of your extension", - "author": "Your Name", - "version": "1.0.0", - "download_url": "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip", - "repository": "https://github.com/your-org/spec-kit-your-extension", - "homepage": "https://github.com/your-org/spec-kit-your-extension", - "documentation": "https://github.com/your-org/spec-kit-your-extension/blob/main/docs/", - "changelog": "https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0", - "tools": [ - { - "name": "required-mcp-tool", - "version": ">=1.0.0", - "required": true - } - ] - }, - "provides": { - "commands": 3, - "hooks": 1 - }, - "tags": [ - "category", - "tool-name", - "feature" - ], - "verified": false, - "downloads": 0, - "stars": 0, - "created_at": "2026-01-28T00:00:00Z", - "updated_at": "2026-01-28T00:00:00Z" - } - } -} -``` - -**Important**: - -- Set `verified: false` (maintainers will verify) -- Set `downloads: 0` and `stars: 0` (auto-updated later) -- Use current timestamp for `created_at` and `updated_at` -- Update the top-level `updated_at` to current time - -### 3. Update Community Extensions Table - -Add your extension to the Community Extensions table in the project root `README.md`: - -```markdown -| Your Extension Name | Brief description of what it does | `` | | [repo-name](https://github.com/your-org/spec-kit-your-extension) | -``` - -**(Table) Category** — pick the one that best fits your extension: - -- `docs` — reads, validates, or generates spec artifacts -- `code` — reviews, validates, or modifies source code -- `process` — orchestrates workflow across phases -- `integration` — syncs with external platforms -- `visibility` — reports on project health or progress - -**Effect** — choose one: - -- Read-only — produces reports without modifying files -- Read+Write — modifies files, creates artifacts, or updates specs - -Insert your extension in alphabetical order in the table. - -### 4. Submit Pull Request - -```bash -# Create a branch -git checkout -b add-your-extension - -# Commit your changes -git add extensions/catalog.community.json README.md -git commit -m "Add your-extension to community catalog - -- Extension ID: your-extension -- Version: 1.0.0 -- Author: Your Name -- Description: Brief description -" - -# Push to your fork -git push origin add-your-extension - -# Create Pull Request on GitHub -# https://github.com/github/spec-kit/compare -``` - -**Pull Request Template**: - -```markdown -## Extension Submission - -**Extension Name**: Your Extension Name -**Extension ID**: your-extension -**Version**: 1.0.0 -**Author**: Your Name -**Repository**: https://github.com/your-org/spec-kit-your-extension - -### Description -Brief description of what your extension does. - -### Checklist -- [x] Valid extension.yml manifest -- [x] README.md with installation and usage docs -- [x] LICENSE file included -- [x] GitHub release created (v1.0.0) -- [x] Extension tested on real project -- [x] All commands working -- [x] No security vulnerabilities -- [x] Added to extensions/catalog.community.json -- [x] Added to Community Extensions table in README.md - -### Testing -Tested on: -- macOS 13.0+ with spec-kit 0.1.0 -- Project: [Your test project] - -### Additional Notes -Any additional context or notes for reviewers. -``` - ---- - -## Verification Process - -### What Happens After Submission - -1. **Automated Checks** (if available): - - Manifest validation - - Download URL accessibility - - Repository existence - - License file presence - -2. **Manual Review**: - - Code quality review - - Security audit - - Functionality testing - - Documentation review - -3. **Verification**: - - If approved, `verified: true` is set - - Extension appears in `specify extension search --verified` - -### Verification Criteria - -To be verified, your extension must: - -✅ **Functionality**: - -- Works as described in documentation -- All commands execute without errors -- No breaking changes to user workflows - -✅ **Security**: - -- No known vulnerabilities -- No malicious code -- Safe handling of user data -- Proper validation of inputs - -✅ **Code Quality**: - -- Clean, readable code -- Follows extension best practices -- Proper error handling -- Helpful error messages - -✅ **Documentation**: - -- Clear installation instructions -- Usage examples -- Troubleshooting section -- Accurate description - -✅ **Maintenance**: - -- Active repository -- Responsive to issues -- Regular updates -- Semantic versioning followed - -### Typical Review Timeline - -- **Automated checks**: Immediate (if implemented) -- **Manual review**: 3-7 business days -- **Verification**: After successful review - ---- - -## Release Workflow - -### Publishing New Versions - -When releasing a new version: +### File an Extension Submission Issue -1. **Update version** in `extension.yml`: +Submit your extension by opening an issue using the **Extension Submission** template: - ```yaml - extension: - version: "1.1.0" # Updated version - ``` +1. Go to [New Issue → Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) +2. Fill in all required fields (ID, name, version, description, URLs, etc.) +3. Complete the testing and submission checklists +4. Submit the issue -2. **Update CHANGELOG.md**: +### What Happens Next - ```markdown - ## [1.1.0] - 2026-02-15 +1. **Automated validation** runs immediately — the bot posts a comment showing which checks passed or failed: + - Extension ID format (lowercase with hyphens) + - Version is valid semver + - Description length (under 200 characters) + - Required URLs are present and reachable + - Spec Kit version constraint is valid + - Tags format and count (2-10 lowercase tags) + - All required fields and checklists are completed +2. If any checks fail, the issue gets a `needs-changes` label with details on what to fix. Edit the issue to correct the fields and validation re-runs automatically. +3. Once all checks pass, the issue gets a `validated` label and a **pull request is created automatically** that: + - Adds your extension to `extensions/catalog.community.json` (alphabetically sorted) + - Is assigned to a maintainer for review +4. A maintainer reviews the generated catalog entry and merges the PR - ### Added - - New feature X +You do **not** need to fork the repository, edit JSON files, or create a PR manually. - ### Fixed - - Bug fix Y - ``` - -3. **Create GitHub release**: - - ```bash - git tag v1.1.0 - git push origin v1.1.0 - # Create release on GitHub - ``` - -4. **Update catalog**: - - ```bash - # Fork spec-kit repo (or update existing fork) - cd spec-kit - - # Update extensions/catalog.json - jq '.extensions["your-extension"].version = "1.1.0"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - jq '.extensions["your-extension"].download_url = "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.1.0.zip"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - jq '.extensions["your-extension"].updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - jq '.updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - - # Submit PR - git checkout -b update-your-extension-v1.1.0 - git add extensions/catalog.json - git commit -m "Update your-extension to v1.1.0" - git push origin update-your-extension-v1.1.0 - ``` - -5. **Submit update PR** with changelog in description +> [!IMPORTANT] +> Maintainers validate that the **catalog metadata is correct** — they do **not** review, audit, or test the extension code itself. Users should review extension source code before installation. --- -## Best Practices - -### Extension Design - -1. **Single Responsibility**: Each extension should focus on one tool/integration -2. **Clear Naming**: Use descriptive, unambiguous names -3. **Minimal Dependencies**: Avoid unnecessary dependencies -4. **Backward Compatibility**: Follow semantic versioning strictly - -### Documentation - -1. **README.md Structure**: - - Overview and features - - Installation instructions - - Configuration guide - - Usage examples - - Troubleshooting - - Contributing guidelines - -2. **Command Documentation**: - - Clear description - - Prerequisites listed - - Step-by-step instructions - - Error handling guidance - - Examples - -3. **Configuration**: - - Provide template file - - Document all options - - Include examples - - Explain defaults - -### Security - -1. **Input Validation**: Validate all user inputs -2. **No Hardcoded Secrets**: Never include credentials -3. **Safe Dependencies**: Only use trusted dependencies -4. **Audit Regularly**: Check for vulnerabilities +## Update Your Extension in the Community Catalog -### Maintenance - -1. **Respond to Issues**: Address issues within 1-2 weeks -2. **Regular Updates**: Keep dependencies updated -3. **Changelog**: Maintain detailed changelog -4. **Deprecation**: Give advance notice for breaking changes - -### Community - -1. **License**: Use permissive open-source license (MIT, Apache 2.0) -2. **Contributing**: Welcome contributions -3. **Code of Conduct**: Be respectful and inclusive -4. **Support**: Provide ways to get help (issues, discussions, email) +After publishing a new release in your extension's repository, file a new [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) issue with the updated version, download URL, and any other changed fields. The automation detects existing entries and processes them as updates (the new version must be higher than the current one). --- @@ -467,27 +74,15 @@ When releasing a new version: ### Q: Can I publish private/proprietary extensions? -A: The main catalog is for public extensions only. For private extensions: - -- Host your own catalog.json file -- Users add your catalog: `specify extension add-catalog https://your-domain.com/catalog.json` -- Not yet implemented - coming in Phase 4 +A: The community catalog is for public extensions only. For private extensions, install directly with `--dev` or `--from` and keep private. -### Q: How long does verification take? +### Q: What if my submission fails validation? -A: Typically 3-7 business days for initial review. Updates to verified extensions are usually faster. - -### Q: What if my extension is rejected? - -A: You'll receive feedback on what needs to be fixed. Make the changes and resubmit. +A: The bot posts a detailed comment showing which checks failed and what to fix. Edit the issue to correct the fields and validation re-runs automatically. ### Q: Can I update my extension anytime? -A: Yes, submit a PR to update the catalog with your new version. Verified status may be re-evaluated for major changes. - -### Q: Do I need to be verified to be in the catalog? - -A: No, unverified extensions are still searchable. Verification just adds trust and visibility. +A: Yes. File a new Extension Submission issue with the updated version and fields. The automation detects that the extension already exists and processes it as an update. ### Q: Can extensions have paid features? @@ -495,67 +90,6 @@ A: Extensions should be free and open-source. Commercial support/services are al --- -## Support - -- **Catalog Issues**: -- **Extension Template**: (coming soon) -- **Development Guide**: See EXTENSION-DEVELOPMENT-GUIDE.md -- **Community**: Discussions and Q&A - ---- - -## Appendix: Catalog Schema - -### Complete Catalog Entry Schema - -```json -{ - "name": "string (required)", - "id": "string (required, unique)", - "description": "string (required, <200 chars)", - "author": "string (required)", - "version": "string (required, semver)", - "download_url": "string (required, valid URL)", - "repository": "string (required, valid URL)", - "homepage": "string (optional, valid URL)", - "documentation": "string (optional, valid URL)", - "changelog": "string (optional, valid URL)", - "license": "string (required)", - "requires": { - "speckit_version": "string (required, version specifier)", - "tools": [ - { - "name": "string (required)", - "version": "string (optional, version specifier)", - "required": "boolean (default: false)" - } - ] - }, - "provides": { - "commands": "integer (optional)", - "hooks": "integer (optional)" - }, - "tags": ["array of strings (2-10 tags)"], - "verified": "boolean (default: false)", - "downloads": "integer (auto-updated)", - "stars": "integer (auto-updated)", - "created_at": "string (ISO 8601 datetime)", - "updated_at": "string (ISO 8601 datetime)" -} -``` - -### Valid Tags - -Recommended tag categories: - -- **Integration**: jira, linear, github, gitlab, azure-devops -- **Category**: issue-tracking, vcs, ci-cd, documentation, testing -- **Platform**: atlassian, microsoft, google -- **Feature**: automation, reporting, deployment, monitoring - -Use 2-5 tags that best describe your extension. - ---- - -*Last Updated: 2026-01-28* -*Catalog Format Version: 1.0* +- **Catalog Issues**: +- **Development Guide**: See [EXTENSION-DEVELOPMENT-GUIDE.md](EXTENSION-DEVELOPMENT-GUIDE.md) +- **Community**: [Discussions](https://github.com/github/spec-kit/discussions) diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index c3391dbc75..6b59bc07d4 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -984,7 +984,7 @@ After creating tasks, sync to Jira: ### Q: How do I know if an extension is safe? -**A**: Look for the ✓ Verified badge. Verified extensions are reviewed by maintainers. Always review extension code before installing. +**A**: Spec Kit supports both **verified** extensions and unverified community extensions. Verified extensions are reviewed and approved according to project policy, and are identified in the catalog/CLI with the verified flag, `--verified` filter, or "✓ Verified" badge. Unverified community extensions have only their catalog metadata validated; they are not reviewed, audited, or tested by maintainers. Always review extension source code before installing an unverified extension. ### Q: Can extensions modify spec-kit core? diff --git a/extensions/README.md b/extensions/README.md index f535ba539a..849014fa4b 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -89,10 +89,9 @@ To add your extension to the community catalog: 1. **Prepare your extension** following the [Extension Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md) 2. **Create a GitHub release** for your extension -3. **Submit a Pull Request** that: - - Adds your extension to `extensions/catalog.community.json` - - Updates this README with your extension in the Available Extensions table -4. **Wait for review** - maintainers will review and merge if criteria are met +3. **Open an issue** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template +4. **Automated validation** checks your submission and a PR is created automatically once all checks pass +5. **Wait for review** — a maintainer reviews and merges the PR See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed step-by-step instructions. diff --git a/integrations/CONTRIBUTING.md b/integrations/CONTRIBUTING.md index 77a50d4d98..b369f7259e 100644 --- a/integrations/CONTRIBUTING.md +++ b/integrations/CONTRIBUTING.md @@ -91,6 +91,8 @@ provides: ### Submitting to the Community Catalog +> **Note**: Automated submission via issue templates is planned. For now, submit manually using the steps below. + 1. **Fork** the [spec-kit repository](https://github.com/github/spec-kit) 2. **Add your entry** under the `integrations` key in `integrations/catalog.community.json`: diff --git a/presets/DEVELOPING.md b/presets/DEVELOPING.md new file mode 100644 index 0000000000..53d5c9b95f --- /dev/null +++ b/presets/DEVELOPING.md @@ -0,0 +1,180 @@ +# Preset Development Guide + +A guide for creating Spec Kit presets. Presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. + +## Table of Contents + +1. [Preset Structure](#preset-structure) +2. [Manifest Validation](#manifest-validation) +3. [Testing](#testing) +4. [Creating a Release](#creating-a-release) +5. [Best Practices](#best-practices) + +--- + +## Preset Structure + +Ensure your preset follows the standard structure: + +```text +your-preset/ +├── preset.yml # Required: Preset manifest +├── README.md # Required: Documentation +├── LICENSE # Required: License file +├── CHANGELOG.md # Recommended: Version history +│ +├── templates/ # Template overrides +│ ├── spec-template.md +│ ├── plan-template.md +│ └── ... +│ +└── commands/ # Command overrides (optional) + └── speckit.specify.md +``` + +Start from the [scaffold](scaffold/) if you're creating a new preset. + +--- + +## Manifest Validation + +Verify your `preset.yml` is valid: + +```yaml +schema_version: "1.0" + +preset: + id: "your-preset" # Unique lowercase-hyphenated ID + name: "Your Preset Name" # Human-readable name + version: "1.0.0" # Semantic version + description: "Brief description (one sentence)" + author: "Your Name or Organization" + repository: "https://github.com/your-org/spec-kit-preset-your-preset" + license: "MIT" + +requires: + speckit_version: ">=0.1.0" # Required spec-kit version + +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-template.md" + description: "Custom spec template" + replaces: "spec-template" + +tags: # 2-10 relevant tags + - "category" + - "workflow" +``` + +**Validation Checklist**: + +- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters) +- ✅ `version` follows semantic versioning (X.Y.Z) +- ✅ `description` is concise (under 200 characters) +- ✅ `repository` URL is valid and public +- ✅ All template and command files exist in the preset directory +- ✅ Template names are lowercase with hyphens only +- ✅ Command names use dot notation (e.g. `speckit.specify`) +- ✅ Tags are lowercase and descriptive + +--- + +## Testing + +### Local Testing + +```bash +# Install from local directory +specify preset add --dev /path/to/your-preset + +# Verify templates resolve from your preset +specify preset resolve spec-template + +# Verify preset info +specify preset info your-preset + +# List installed presets +specify preset list + +# Remove when done testing +specify preset remove your-preset +``` + +### Verify Command Registration + +If your preset includes command overrides, verify they appear in the agent directories: + +```bash +# Check Claude commands (if using Claude) +ls .claude/commands/speckit.*.md + +# Check Copilot commands (if using Copilot) +ls .github/agents/speckit.*.agent.md + +# Check Gemini commands (if using Gemini) +ls .gemini/commands/speckit.*.toml +``` + +--- + +## Creating a Release + +Create a GitHub release for your preset version: + +```bash +# Tag the release +git tag v1.0.0 +git push origin v1.0.0 +``` + +The release archive URL will be: + +```text +https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip +``` + +### Test Installation from Archive + +```bash +specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip +``` + +--- + +## Best Practices + +### Template Design + +- **Keep sections clear** — use headings and placeholder text the LLM can replace +- **Match commands to templates** — if your preset overrides a command, make sure it references the sections in your template +- **Document customization points** — use HTML comments to guide users on what to change + +### Naming + +- Preset IDs should be descriptive: `healthcare-compliance`, `enterprise-safe`, `startup-lean` +- Avoid generic names: `my-preset`, `custom`, `test` + +### Stacking + +- Design presets to work well when stacked with others +- Only override templates you need to change +- Document which templates and commands your preset modifies + +### Command Overrides + +- Only override commands when the workflow needs to change, not just the output format +- If you only need different template sections, a template override is sufficient +- Test command overrides with multiple agents (Claude, Gemini, Copilot) + +### Maintenance + +- **Respond to issues**: Address issues in a timely manner +- **Keep a changelog**: Document changes in CHANGELOG.md +- **Deprecation notices**: Give advance notice for breaking changes +- **Use a permissive license**: MIT or Apache 2.0 recommended + +--- + +When your preset is ready to share, see the [Publishing Guide](PUBLISHING.md) to submit it to the community catalog. diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md index 661614e5c0..0e3bc3989e 100644 --- a/presets/PUBLISHING.md +++ b/presets/PUBLISHING.md @@ -1,155 +1,30 @@ # Preset Publishing Guide -This guide explains how to publish your preset to the Spec Kit preset catalog, making it discoverable by `specify preset search`. +This guide explains how to publish your preset to the Spec Kit community catalog, making it discoverable by `specify preset search`. + +For how to develop and test a preset, see the [Development Guide](DEVELOPING.md). ## Table of Contents 1. [Prerequisites](#prerequisites) -2. [Prepare Your Preset](#prepare-your-preset) -3. [Submit to Catalog](#submit-to-catalog) -4. [Verification Process](#verification-process) -5. [Release Workflow](#release-workflow) -6. [Best Practices](#best-practices) +2. [Submit a New Preset to the Community Catalog](#submit-a-new-preset-to-the-community-catalog) +3. [Update Your Preset in the Community Catalog](#update-your-preset-in-the-community-catalog) +4. [FAQ](#faq) --- ## Prerequisites -Before publishing a preset, ensure you have: +Before publishing, ensure you have: -1. **Valid Preset**: A working preset with a valid `preset.yml` manifest -2. **Git Repository**: Preset hosted on GitHub (or other public git hosting) -3. **Documentation**: README.md with description and usage instructions -4. **License**: Open source license file (MIT, Apache 2.0, etc.) -5. **Versioning**: Semantic versioning (e.g., 1.0.0) -6. **Testing**: Preset tested on real projects with `specify preset add --dev` +1. **A working preset** — developed and tested per the [Development Guide](DEVELOPING.md) +2. **A GitHub release** — with a release tag for the version you are publishing (the tag is commonly `v1.0.0`; the catalog Version field should be `1.0.0` without the `v` prefix) +3. **A valid download URL** — the release archive URL for your tag +4. **An open-source license** — MIT, Apache 2.0, etc. --- -## Prepare Your Preset - -### 1. Preset Structure - -Ensure your preset follows the standard structure: - -```text -your-preset/ -├── preset.yml # Required: Preset manifest -├── README.md # Required: Documentation -├── LICENSE # Required: License file -├── CHANGELOG.md # Recommended: Version history -│ -├── templates/ # Template overrides -│ ├── spec-template.md -│ ├── plan-template.md -│ └── ... -│ -└── commands/ # Command overrides (optional) - └── speckit.specify.md -``` - -Start from the [scaffold](scaffold/) if you're creating a new preset. - -### 2. preset.yml Validation - -Verify your manifest is valid: - -```yaml -schema_version: "1.0" - -preset: - id: "your-preset" # Unique lowercase-hyphenated ID - name: "Your Preset Name" # Human-readable name - version: "1.0.0" # Semantic version - description: "Brief description (one sentence)" - author: "Your Name or Organization" - repository: "https://github.com/your-org/spec-kit-preset-your-preset" - license: "MIT" - -requires: - speckit_version: ">=0.1.0" # Required spec-kit version - -provides: - templates: - - type: "template" - name: "spec-template" - file: "templates/spec-template.md" - description: "Custom spec template" - replaces: "spec-template" - -tags: # 2-5 relevant tags - - "category" - - "workflow" -``` - -**Validation Checklist**: - -- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters) -- ✅ `version` follows semantic versioning (X.Y.Z) -- ✅ `description` is concise (under 200 characters) -- ✅ `repository` URL is valid and public -- ✅ All template and command files exist in the preset directory -- ✅ Template names are lowercase with hyphens only -- ✅ Command names use dot notation (e.g. `speckit.specify`) -- ✅ Tags are lowercase and descriptive - -### 3. Test Locally - -```bash -# Install from local directory -specify preset add --dev /path/to/your-preset - -# Verify templates resolve from your preset -specify preset resolve spec-template - -# Verify preset info -specify preset info your-preset - -# List installed presets -specify preset list - -# Remove when done testing -specify preset remove your-preset -``` - -If your preset includes command overrides, verify they appear in the agent directories: - -```bash -# Check Claude commands (if using Claude) -ls .claude/commands/speckit.*.md - -# Check Copilot commands (if using Copilot) -ls .github/agents/speckit.*.agent.md - -# Check Gemini commands (if using Gemini) -ls .gemini/commands/speckit.*.toml -``` - -### 4. Create GitHub Release - -Create a GitHub release for your preset version: - -```bash -# Tag the release -git tag v1.0.0 -git push origin v1.0.0 -``` - -The release archive URL will be: - -```text -https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip -``` - -### 5. Test Installation from Archive - -```bash -specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip -``` - ---- - -## Submit to Catalog +## Submit a New Preset to the Community Catalog ### Understanding the Catalogs @@ -160,147 +35,54 @@ Spec Kit uses a dual-catalog system: All community presets should be submitted to `catalog.community.json`. -### 1. Fork the spec-kit Repository - -```bash -git clone https://github.com/YOUR-USERNAME/spec-kit.git -cd spec-kit -``` - -### 2. Add Preset to Community Catalog - -Edit `presets/catalog.community.json` and add your preset. - -> **⚠️ Entries must be sorted alphabetically by preset ID.** Insert your preset in the correct position within the `"presets"` object. - -```json -{ - "schema_version": "1.0", - "updated_at": "2026-03-10T00:00:00Z", - "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", - "presets": { - "your-preset": { - "name": "Your Preset Name", - "description": "Brief description of what your preset provides", - "author": "Your Name", - "version": "1.0.0", - "download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip", - "repository": "https://github.com/your-org/spec-kit-preset-your-preset", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0" - }, - "provides": { - "templates": 3, - "commands": 1 - }, - "tags": [ - "category", - "workflow" - ], - "created_at": "2026-03-10T00:00:00Z", - "updated_at": "2026-03-10T00:00:00Z" - } - } -} -``` - -### 3. Update Community Presets Table - -Add your preset to the Community Presets table on the docs site at `docs/community/presets.md`: - -```markdown -| Your Preset Name | Brief description of what your preset does | N templates, M commands[, P scripts] | — | [repo-name](https://github.com/your-org/spec-kit-preset-your-preset) | -``` +### File a Preset Submission Issue -Insert your row in alphabetical order by preset **name** (the first column of the table). +Submit your preset by opening an issue using the **Preset Submission** template: -### 4. Submit Pull Request +1. Go to [New Issue → Preset Submission](https://github.com/github/spec-kit/issues/new?template=preset_submission.yml) +2. Fill in all required fields (ID, name, version, description, URLs, etc.) +3. List the templates and commands your preset provides +4. Complete the testing and submission checklists +5. Submit the issue -```bash -git checkout -b add-your-preset -git add presets/catalog.community.json docs/community/presets.md -git commit -m "Add your-preset to community catalog +### What Happens Next -- Preset ID: your-preset -- Version: 1.0.0 -- Author: Your Name -- Description: Brief description -" -git push origin add-your-preset -``` +1. **Automated validation** runs immediately — the bot posts a comment showing which checks passed or failed: + - Preset ID format (lowercase with hyphens) + - Version is valid semver + - Description length (under 200 characters) + - Required URLs are present and reachable + - Spec Kit version constraint is valid + - Tags format and count (2-10 lowercase tags) + - All required fields and checklists are completed +2. If any checks fail, the issue gets a `needs-changes` label with details on what to fix. Edit the issue to correct the fields and validation re-runs automatically. +3. Once all checks pass, the issue gets a `validated` label and a **pull request is created automatically** that: + - Adds your preset to `presets/catalog.community.json` (alphabetically sorted) + - Regenerates the community presets table in the docs + - Is assigned to a maintainer for review +4. A maintainer reviews the generated catalog entry and merges the PR -**Pull Request Checklist**: +You do **not** need to fork the repository, edit JSON files, or create a PR manually. -```markdown -## Preset Submission +> [!IMPORTANT] +> Maintainers validate that the **catalog metadata is correct** — they do **not** review, audit, or test the preset code itself. Users should review preset source code before installation. -**Preset Name**: Your Preset Name -**Preset ID**: your-preset -**Version**: 1.0.0 -**Repository**: https://github.com/your-org/spec-kit-preset-your-preset +## Update Your Preset in the Community Catalog -### Checklist -- [ ] Valid preset.yml manifest -- [ ] README.md with description and usage -- [ ] LICENSE file included -- [ ] GitHub release created -- [ ] Preset tested with `specify preset add --dev` -- [ ] Templates resolve correctly (`specify preset resolve`) -- [ ] Commands register to agent directories (if applicable) -- [ ] Commands match template sections (command + template are coherent) -- [ ] Added to presets/catalog.community.json -- [ ] Added row to docs/community/presets.md table -``` +After publishing a new release in your preset's repository, file a new [Preset Submission](https://github.com/github/spec-kit/issues/new?template=preset_submission.yml) issue with the updated version and download URL. The automation detects existing entries and processes them as updates (the new version must be higher than the current one). --- -## Verification Process - -After submission, maintainers will review: - -1. **Manifest validation** — valid `preset.yml`, all files exist -2. **Template quality** — templates are useful and well-structured -3. **Command coherence** — commands reference sections that exist in templates -4. **Security** — no malicious content, safe file operations -5. **Documentation** — clear README explaining what the preset does - -Once verified, `verified: true` is set and the preset appears in `specify preset search`. - ---- - -## Release Workflow - -When releasing a new version: - -1. Update `version` in `preset.yml` -2. Update CHANGELOG.md -3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0` -4. Submit PR to update `version` and `download_url` in `presets/catalog.community.json` - ---- - -## Best Practices - -### Template Design - -- **Keep sections clear** — use headings and placeholder text the LLM can replace -- **Match commands to templates** — if your preset overrides a command, make sure it references the sections in your template -- **Document customization points** — use HTML comments to guide users on what to change +## FAQ -### Naming +### Q: Can I publish private/proprietary presets? -- Preset IDs should be descriptive: `healthcare-compliance`, `enterprise-safe`, `startup-lean` -- Avoid generic names: `my-preset`, `custom`, `test` +A: The community catalog is for public presets only. For private presets, install directly with `--dev` or `--from` and keep private. -### Stacking +### Q: What if my submission fails validation? -- Design presets to work well when stacked with others -- Only override templates you need to change -- Document which templates and commands your preset modifies +A: The bot posts a detailed comment showing which checks failed and what to fix. Edit the issue to correct the fields and validation re-runs automatically. -### Command Overrides +### Q: Can I update my preset anytime? -- Only override commands when the workflow needs to change, not just the output format -- If you only need different template sections, a template override is sufficient -- Test command overrides with multiple agents (Claude, Gemini, Copilot) +A: Yes. File a new Preset Submission issue with the updated version and fields. The automation detects that the preset already exists and processes it as an update. diff --git a/tests/test_catalog_validate.py b/tests/test_catalog_validate.py new file mode 100644 index 0000000000..6e5e9840cb --- /dev/null +++ b/tests/test_catalog_validate.py @@ -0,0 +1,197 @@ +"""Tests for .github/scripts/catalog-validate.py.""" + +from __future__ import annotations + +import importlib +from pathlib import Path +from unittest.mock import patch + +import pytest + +# --------------------------------------------------------------------------- +# Import the script as a module +# --------------------------------------------------------------------------- + +SCRIPT = Path(__file__).resolve().parents[1] / ".github" / "scripts" / "catalog-validate.py" + + +@pytest.fixture(scope="module") +def cv(): + """Import catalog-validate.py as a module.""" + spec = importlib.util.spec_from_file_location("catalog_validate", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# --------------------------------------------------------------------------- +# parse_issue_body +# --------------------------------------------------------------------------- + + +class TestParseIssueBody: + def test_basic_fields(self, cv): + body = "### Name\nAlice\n### Version\n1.0.0\n" + result = cv.parse_issue_body(body) + assert result["Name"] == "Alice" + assert result["Version"] == "1.0.0" + + def test_multiline_field(self, cv): + body = "### Description\nLine one\nLine two\n### End\nval" + result = cv.parse_issue_body(body) + assert "Line one\nLine two" == result["Description"] + + def test_empty_body(self, cv): + assert cv.parse_issue_body("") == {} + + def test_checkbox_field(self, cv): + body = "### Checklist\n- [X] Item 1\n- [ ] Item 2\n" + result = cv.parse_issue_body(body) + assert "- [X] Item 1" in result["Checklist"] + + +# --------------------------------------------------------------------------- +# parse_tags / validate_tags +# --------------------------------------------------------------------------- + + +class TestTags: + def test_parse_tags_dedup(self, cv): + assert cv.parse_tags("foo, foo, bar") == ["bar", "foo"] + + def test_parse_tags_sorted(self, cv): + assert cv.parse_tags("z, a, m") == ["a", "m", "z"] + + def test_validate_tags_too_few(self, cv): + ok, _ = cv.validate_tags("single") + assert not ok + + def test_validate_tags_too_many(self, cv): + ok, _ = cv.validate_tags(", ".join(f"tag{i}" for i in range(11))) + assert not ok + + def test_validate_tags_max_10(self, cv): + ok, _ = cv.validate_tags(", ".join(f"tag{i}" for i in range(10))) + assert ok + + def test_validate_tags_bad_chars(self, cv): + ok, _ = cv.validate_tags("good, BAD CHARS!") + assert not ok + + def test_validate_tags_dedup_count(self, cv): + # 3 raw tags but only 2 unique — should still pass (>= 2) + ok, msg = cv.validate_tags("foo, foo, bar") + assert ok + assert "duplicate" in msg.lower() + + def test_validate_tags_dedup_too_few(self, cv): + # All dupes of same tag — only 1 unique + ok, _ = cv.validate_tags("foo, foo, foo") + assert not ok + + +# --------------------------------------------------------------------------- +# validate_description +# --------------------------------------------------------------------------- + + +class TestValidateDescription: + def test_empty(self, cv): + ok, _ = cv.validate_description("") + assert not ok + + def test_valid(self, cv): + ok, _ = cv.validate_description("Short description") + assert ok + + def test_over_limit_new(self, cv): + ok, _ = cv.validate_description("x" * 201) + assert not ok + + def test_over_limit_update_warns(self, cv): + ok, msg = cv.validate_description("x" * 201, is_update=True) + assert ok + assert "Consider shortening" in msg + + +# --------------------------------------------------------------------------- +# validate_speckit_version +# --------------------------------------------------------------------------- + + +class TestValidateSpeckitVersion: + def test_valid(self, cv): + ok, _ = cv.validate_speckit_version(">=0.6.0") + assert ok + + def test_valid_multi(self, cv): + ok, _ = cv.validate_speckit_version(">=0.6.0,<1.0.0") + assert ok + + def test_invalid(self, cv): + ok, _ = cv.validate_speckit_version("not-a-version") + assert not ok + + def test_empty(self, cv): + ok, _ = cv.validate_speckit_version("") + assert not ok + + +# --------------------------------------------------------------------------- +# _count_list_items +# --------------------------------------------------------------------------- + + +class TestCountListItems: + def test_bullets(self, cv): + assert cv._count_list_items("- one\n- two\n- three") == 3 + + def test_asterisks(self, cv): + assert cv._count_list_items("* one\n* two") == 2 + + def test_plain_lines(self, cv): + assert cv._count_list_items("one\ntwo\nthree") == 3 + + def test_empty(self, cv): + assert cv._count_list_items("") == 0 + + def test_mixed_blank(self, cv): + assert cv._count_list_items("- one\n\n- two") == 2 + + +# --------------------------------------------------------------------------- +# SSRF guard (_is_safe_redirect_target) +# --------------------------------------------------------------------------- + + +class TestSSRFGuard: + def test_rejects_private_ip(self, cv): + assert not cv._is_safe_redirect_target("http://127.0.0.1/evil") + + def test_rejects_non_http(self, cv): + assert not cv._is_safe_redirect_target("ftp://example.com/file") + + def test_rejects_no_hostname(self, cv): + assert not cv._is_safe_redirect_target("http:///path") + + def test_allows_public(self, cv): + # Mock DNS to return a public IP + fake_addr = [(None, None, None, None, ("93.184.216.34", 0))] + with patch.object(cv.socket, "getaddrinfo", return_value=fake_addr): + assert cv._is_safe_redirect_target("https://example.com") + + def test_rejects_multicast(self, cv): + fake_addr = [(None, None, None, None, ("224.0.0.1", 0))] + with patch.object(cv.socket, "getaddrinfo", return_value=fake_addr): + assert not cv._is_safe_redirect_target("https://multicast.test") + + def test_rejects_unspecified(self, cv): + fake_addr = [(None, None, None, None, ("0.0.0.0", 0))] + with patch.object(cv.socket, "getaddrinfo", return_value=fake_addr): + assert not cv._is_safe_redirect_target("https://zero.test") + + def test_rejects_unresolvable(self, cv): + """DNS failure should fail closed (block, not allow).""" + import socket as _socket + with patch.object(cv.socket, "getaddrinfo", side_effect=_socket.gaierror): + assert not cv._is_safe_redirect_target("https://unresolvable.test")