Skip to content

Commit 0e86c76

Browse files
committed
feat: automate community catalog submissions with validation and PR generation
Add GitHub Actions workflows and scripts to automate extension and preset catalog submissions. Validation is metadata-only (no archive extraction). - catalog-validate.yml: auto-validates submission issues - catalog-pr.yml: generates PR to update catalog.community.json - catalog-validate.py: issue parsing, field validation, URL reachability - catalog-pr.py: catalog entry generation and PR creation - catalog-generate-table.py: formatted catalog table generation - Updated publishing/development guides - New presets/DEVELOPING.md Closes #2400
1 parent 171b65a commit 0e86c76

13 files changed

Lines changed: 1883 additions & 791 deletions

.github/CODEOWNERS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
# Global code owner
22
* @mnriem
33

4+
# Community catalog files — require maintainer approval even for bot PRs
5+
extensions/catalog.community.json @mnriem
6+
integrations/catalog.community.json @mnriem
7+
presets/catalog.community.json @mnriem
8+
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
#!/usr/bin/env python3
2+
"""Generate a markdown table from a community catalog JSON file.
3+
4+
Reads a catalog.community.json and replaces content between marker comments
5+
in a target markdown file. If the markers are not present the table is
6+
printed to stdout.
7+
8+
Markers expected in the markdown file:
9+
<!-- catalog-table-start -->
10+
... (old table content replaced) ...
11+
<!-- catalog-table-end -->
12+
13+
Usage:
14+
python .github/scripts/catalog-generate-table.py \
15+
--catalog presets/catalog.community.json \
16+
--type preset \
17+
--target docs/community/presets.md
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import argparse
23+
import json
24+
import re
25+
import sys
26+
from pathlib import Path
27+
28+
START_MARKER = "<!-- catalog-table-start -->"
29+
END_MARKER = "<!-- catalog-table-end -->"
30+
31+
32+
# ---------------------------------------------------------------------------
33+
# Table builders — one per catalog type
34+
# ---------------------------------------------------------------------------
35+
36+
def _repo_display_name(url: str) -> str:
37+
"""Extract the repository name from a GitHub URL."""
38+
# https://github.com/user/spec-kit-foo → spec-kit-foo
39+
return url.rstrip("/").rsplit("/", 1)[-1]
40+
41+
42+
def _provides_str_preset(provides: dict) -> str:
43+
parts: list[str] = []
44+
t = provides.get("templates", 0)
45+
c = provides.get("commands", 0)
46+
s = provides.get("scripts", 0)
47+
if t:
48+
parts.append(f"{t} template{'s' if t != 1 else ''}")
49+
if c:
50+
parts.append(f"{c} command{'s' if c != 1 else ''}")
51+
if s:
52+
parts.append(f"{s} script{'s' if s != 1 else ''}")
53+
return ", ".join(parts) or "—"
54+
55+
56+
def _requires_str_preset(requires: dict) -> str:
57+
exts = requires.get("extensions", [])
58+
if exts:
59+
return ", ".join(f"{e} extension" for e in exts)
60+
return "—"
61+
62+
63+
def build_preset_table(catalog: dict) -> str:
64+
"""Build a markdown table for presets."""
65+
entries = catalog.get("presets", {})
66+
lines: list[str] = []
67+
lines.append("| Preset | Purpose | Provides | Requires | URL |")
68+
lines.append("|--------|---------|----------|----------|-----|")
69+
70+
for _id in sorted(entries):
71+
e = entries[_id]
72+
name = e.get("name", _id)
73+
desc = e.get("description", "")
74+
provides = _provides_str_preset(e.get("provides", {}))
75+
requires = _requires_str_preset(e.get("requires", {}))
76+
repo_url = e.get("repository", "")
77+
repo_name = _repo_display_name(repo_url)
78+
lines.append(
79+
f"| {name} | {desc} | {provides} | {requires} "
80+
f"| [{repo_name}]({repo_url}) |"
81+
)
82+
83+
return "\n".join(lines)
84+
85+
86+
def _provides_str_extension(provides: dict) -> str:
87+
parts: list[str] = []
88+
c = provides.get("commands", 0)
89+
h = provides.get("hooks", 0)
90+
if c:
91+
parts.append(f"{c} command{'s' if c != 1 else ''}")
92+
if h:
93+
parts.append(f"{h} hook{'s' if h != 1 else ''}")
94+
return ", ".join(parts) or "—"
95+
96+
97+
def build_extension_table(catalog: dict) -> str:
98+
"""Build a markdown table for extensions."""
99+
entries = catalog.get("extensions", {})
100+
lines: list[str] = []
101+
lines.append("| Extension | Purpose | Provides | URL |")
102+
lines.append("|-----------|---------|----------|-----|")
103+
104+
for _id in sorted(entries):
105+
e = entries[_id]
106+
name = e.get("name", _id)
107+
desc = e.get("description", "")
108+
provides = _provides_str_extension(e.get("provides", {}))
109+
repo_url = e.get("repository", "")
110+
repo_name = _repo_display_name(repo_url)
111+
lines.append(
112+
f"| {name} | {desc} | {provides} "
113+
f"| [{repo_name}]({repo_url}) |"
114+
)
115+
116+
return "\n".join(lines)
117+
118+
119+
BUILDERS = {
120+
"preset": build_preset_table,
121+
"extension": build_extension_table,
122+
}
123+
124+
125+
# ---------------------------------------------------------------------------
126+
# File updater
127+
# ---------------------------------------------------------------------------
128+
129+
def update_file(path: Path, table: str) -> bool:
130+
"""Replace content between markers in *path*. Returns True if updated."""
131+
content = path.read_text()
132+
133+
pattern = re.compile(
134+
rf"({re.escape(START_MARKER)})\n.*?\n({re.escape(END_MARKER)})",
135+
re.DOTALL,
136+
)
137+
138+
if not pattern.search(content):
139+
return False
140+
141+
new_content = pattern.sub(rf"\1\n{table}\n\2", content)
142+
143+
if new_content != content:
144+
path.write_text(new_content)
145+
return True
146+
return False
147+
148+
149+
# ---------------------------------------------------------------------------
150+
# Main
151+
# ---------------------------------------------------------------------------
152+
153+
def main() -> None:
154+
parser = argparse.ArgumentParser(description=__doc__)
155+
parser.add_argument(
156+
"--catalog", required=True,
157+
help="Path to catalog.community.json",
158+
)
159+
parser.add_argument(
160+
"--type", required=True, choices=list(BUILDERS),
161+
help="Catalog type",
162+
)
163+
parser.add_argument(
164+
"--target",
165+
help="Markdown file to update (must contain marker comments)",
166+
)
167+
args = parser.parse_args()
168+
169+
with open(args.catalog) as f:
170+
catalog = json.load(f)
171+
172+
builder = BUILDERS[args.type]
173+
table = builder(catalog)
174+
175+
if args.target:
176+
target = Path(args.target)
177+
if not target.exists():
178+
print(f"Error: target file not found: {target}", file=sys.stderr)
179+
sys.exit(1)
180+
if update_file(target, table):
181+
print(f"Updated {target}")
182+
else:
183+
print(
184+
f"Warning: markers {START_MARKER} / {END_MARKER} not found "
185+
f"in {target}. Printing table to stdout.",
186+
file=sys.stderr,
187+
)
188+
print(table)
189+
else:
190+
print(table)
191+
192+
193+
if __name__ == "__main__":
194+
main()

.github/scripts/catalog-pr.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/usr/bin/env python3
2+
"""Update a community catalog JSON file with a validated entry.
3+
4+
Reads the validation result and entry produced by catalog-validate.py,
5+
inserts or replaces the entry in the catalog, sorts entries alphabetically,
6+
and optionally regenerates a docs table.
7+
8+
Usage (typically called from GitHub Actions):
9+
python .github/scripts/catalog-pr.py \
10+
--catalog extensions/catalog.community.json \
11+
--type extension
12+
13+
python .github/scripts/catalog-pr.py \
14+
--catalog presets/catalog.community.json \
15+
--type preset \
16+
--table-target docs/community/presets.md
17+
18+
Environment variables:
19+
GITHUB_OUTPUT — Path to GitHub Actions output file (optional)
20+
21+
Inputs (files produced by catalog-validate.py):
22+
/tmp/validation-result.json — metadata including item_id, valid, is_update
23+
/tmp/catalog-entry.json — the entry to insert/replace
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import argparse
29+
import json
30+
import os
31+
import subprocess
32+
import sys
33+
from datetime import datetime, timezone
34+
from pathlib import Path
35+
36+
CATALOG_KEY = {
37+
"extension": "extensions",
38+
"preset": "presets",
39+
}
40+
41+
42+
def main() -> None:
43+
parser = argparse.ArgumentParser(description=__doc__)
44+
parser.add_argument(
45+
"--catalog", required=True,
46+
help="Path to the catalog JSON file",
47+
)
48+
parser.add_argument(
49+
"--type", required=True, choices=list(CATALOG_KEY),
50+
help="Catalog type",
51+
)
52+
parser.add_argument(
53+
"--result", default="/tmp/validation-result.json",
54+
help="Path to validation result JSON",
55+
)
56+
parser.add_argument(
57+
"--entry", default="/tmp/catalog-entry.json",
58+
help="Path to catalog entry JSON",
59+
)
60+
parser.add_argument(
61+
"--table-target",
62+
help="Markdown file to regenerate table in (must contain marker comments)",
63+
)
64+
args = parser.parse_args()
65+
66+
# Load validation result
67+
result_path = Path(args.result)
68+
if not result_path.exists():
69+
print(f"Error: result file not found: {result_path}", file=sys.stderr)
70+
sys.exit(2)
71+
result = json.loads(result_path.read_text())
72+
73+
if not result.get("valid"):
74+
print("Submission is not valid — skipping catalog update.")
75+
_set_output("skipped", "true")
76+
sys.exit(0)
77+
78+
# Load entry
79+
entry_path = Path(args.entry)
80+
if not entry_path.exists():
81+
print(f"Error: entry file not found: {entry_path}", file=sys.stderr)
82+
sys.exit(2)
83+
new_entry = json.loads(entry_path.read_text())
84+
85+
item_id = result["item_id"]
86+
is_update = result.get("is_update", False)
87+
cat_key = CATALOG_KEY[args.type]
88+
89+
# Update catalog
90+
catalog_path = Path(args.catalog)
91+
with open(catalog_path) as f:
92+
catalog = json.load(f)
93+
94+
catalog[cat_key][item_id] = new_entry
95+
catalog["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00Z")
96+
catalog[cat_key] = dict(sorted(catalog[cat_key].items()))
97+
98+
with open(catalog_path, "w") as f:
99+
json.dump(catalog, f, indent=2)
100+
f.write("\n")
101+
102+
print(f"Updated {catalog_path}: {'replaced' if is_update else 'added'} {item_id}")
103+
104+
# Regenerate docs table if requested
105+
if args.table_target:
106+
table_script = Path(__file__).parent / "catalog-generate-table.py"
107+
subprocess.run(
108+
[
109+
sys.executable, str(table_script),
110+
"--catalog", args.catalog,
111+
"--type", args.type,
112+
"--target", args.table_target,
113+
],
114+
check=True,
115+
)
116+
117+
# Set outputs for the workflow
118+
action = "update" if is_update else "add"
119+
_set_output("skipped", "false")
120+
_set_output("item_id", item_id)
121+
_set_output("is_update", str(is_update).lower())
122+
_set_output("action", action.title())
123+
_set_output("action_verb", f"{action.title()}s")
124+
_set_output("branch", f"community/{action}-{args.type}-{item_id}")
125+
126+
127+
def _set_output(name: str, value: str) -> None:
128+
"""Write a GitHub Actions output variable."""
129+
gh_output = os.environ.get("GITHUB_OUTPUT")
130+
if gh_output:
131+
with open(gh_output, "a") as f:
132+
f.write(f"{name}={value}\n")
133+
# Also print for local debugging
134+
print(f" output: {name}={value}")
135+
136+
137+
if __name__ == "__main__":
138+
main()

0 commit comments

Comments
 (0)