Files
iLSP/scripts/sync_pipeline_templates.py
Henrik Jess Nielsen 333d986e76
All checks were successful
Build and Deploy iLSP / test (push) Successful in 25s
Build and Deploy iLSP / build-and-deploy (push) Successful in 1m34s
feat: YAML pipeline template autocomplete (AzDO + GHA)
- Add scripts/sync_pipeline_templates.py — scans LRU AzDO and GHA template
  repos; outputs unified pipeline_templates_catalog.json (48 templates: 45
  AzDO + 3 GHA)
- Add scripts/template_sources.yml — source config (AzDO alias, GHA org)
- Add pipeline_templates_catalog.json — baked catalog (49 KB)
- Add ilsp/yaml_lsp/catalog.py — PipelineTemplateCatalog with completion item
  generators for template paths, param names, allowed values, GHA inputs
- Add ilsp/yaml_lsp/proxy.py — async WS↔TCP bridge with LSP frame buffering,
  per-connection document tracking, AzDO/GHA context detection, and completion
  injection (LRU items sortText 0_, standard items downgraded to 9_)
- Wire yaml_ws_handler into server.py (replaces raw _ws_proxy call)
- Load PipelineTemplateCatalog at startup; reload + health report template count
- Update push_catalogs.sh to push pipeline_templates_catalog.json
- Update Dockerfile to bake pipeline_templates_catalog.json as image fallback
- Add tests/test_yaml_catalog.py (14 tests) + tests/test_yaml_proxy.py (18 tests)
  All 67 tests green
2026-05-10 15:59:37 +02:00

242 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""
sync_pipeline_templates.py — Build pipeline_templates_catalog.json from:
- AzDO template repos (parameters: list format)
- GitHub Actions reusable workflows (on.workflow_call.inputs format)
Usage:
python3 scripts/sync_pipeline_templates.py # scan both formats
python3 scripts/sync_pipeline_templates.py --mode azdo # AzDO only
python3 scripts/sync_pipeline_templates.py --mode gha # GHA only
python3 scripts/sync_pipeline_templates.py --dry-run # preview, no write
python3 scripts/sync_pipeline_templates.py --output /path/to/catalog.json
"""
import argparse
import json
import logging
import os
import pathlib
import sys
from datetime import datetime, timezone
from typing import Any
import yaml
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger(__name__)
_REPO_ROOT = pathlib.Path(__file__).parent.parent
_SOURCES_FILE = pathlib.Path(__file__).parent / "template_sources.yml"
_DEFAULT_OUTPUT = _REPO_ROOT / "pipeline_templates_catalog.json"
# ── AzDO template scanner ────────────────────────────────────────────────────
def _parse_azdo_params(raw: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract parameter definitions from an AzDO template dict."""
params = raw.get("parameters", [])
if not isinstance(params, list):
return []
result = []
for p in params:
if not isinstance(p, dict) or "name" not in p:
continue
entry: dict[str, Any] = {
"name": p["name"],
"type": p.get("type", "string"),
"required": "default" not in p,
}
if "default" in p:
entry["default"] = p["default"]
allowed = p.get("values", [])
if isinstance(allowed, list) and allowed:
entry["allowed"] = [str(v) for v in allowed]
if "displayName" in p:
entry["description"] = p["displayName"]
result.append(entry)
return result
def scan_azdo_source(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Scan an AzDO template directory. Returns {key: template_entry}."""
alias = config["alias"]
base = pathlib.Path(config["local_path"]).expanduser()
if not base.exists():
log.warning("AzDO source '%s' not found at %s — skipping", alias, base)
return {}
scan_dirs = config.get("scan_dirs", [])
extensions = set(config.get("extensions", [".yaml", ".yml"]))
if scan_dirs:
candidates = []
for d in scan_dirs:
candidates.extend((base / d).rglob("*"))
else:
candidates = list(base.rglob("*"))
results: dict[str, dict[str, Any]] = {}
for fpath in candidates:
if fpath.suffix not in extensions or not fpath.is_file():
continue
try:
raw = yaml.safe_load(fpath.read_text(encoding="utf-8"))
except Exception as exc:
log.debug("Cannot parse %s: %s", fpath, exc)
continue
if not isinstance(raw, dict):
continue
params = _parse_azdo_params(raw)
if not params:
continue # Not a template file (no parameters block)
rel = fpath.relative_to(base).as_posix()
key = f"{rel}@{alias}"
results[key] = {
"format": "azdo",
"title": fpath.stem,
"path": rel,
"alias": alias,
"parameters": params,
}
log.info("AzDO '%s': %d templates found", alias, len(results))
return results
# ── GHA reusable workflow scanner ────────────────────────────────────────────
def _parse_gha_inputs(raw: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract workflow_call.inputs from a GHA workflow dict."""
on = raw.get("on") or raw.get(True) # 'on' is a YAML bool alias
if not isinstance(on, dict):
return []
wc = on.get("workflow_call", {})
if not isinstance(wc, dict):
return []
inputs = wc.get("inputs", {})
if not isinstance(inputs, dict):
return []
result = []
for name, meta in inputs.items():
if not isinstance(meta, dict):
meta = {}
entry: dict[str, Any] = {
"name": name,
"type": meta.get("type", "string"),
"required": meta.get("required", False),
}
if "default" in meta:
entry["default"] = meta["default"]
if "description" in meta:
entry["description"] = meta["description"]
# GHA doesn't have allowed values natively — skip
result.append(entry)
return result
def scan_gha_source(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Scan GitHub Actions repos for reusable workflows. Returns {key: template_entry}."""
org = config["org"]
base = pathlib.Path(config["local_base"]).expanduser()
default_ref = config.get("default_ref", "main")
repos_filter = config.get("repos", [])
if not base.exists():
log.warning("GHA base '%s' not found at %s — skipping", org, base)
return {}
results: dict[str, dict[str, Any]] = {}
repos = [base / r for r in repos_filter] if repos_filter else [p for p in base.iterdir() if p.is_dir()]
for repo_path in repos:
wf_dir = repo_path / ".github" / "workflows"
if not wf_dir.is_dir():
continue
repo_name = repo_path.name
for fpath in wf_dir.glob("*.yml"):
try:
raw = yaml.safe_load(fpath.read_text(encoding="utf-8"))
except Exception as exc:
log.debug("Cannot parse %s: %s", fpath, exc)
continue
if not isinstance(raw, dict):
continue
params = _parse_gha_inputs(raw)
if not params:
continue # Not a reusable workflow
rel_wf = f".github/workflows/{fpath.name}"
key = f"{org}/{repo_name}/{rel_wf}@{default_ref}"
results[key] = {
"format": "gha",
"title": fpath.stem,
"org": org,
"repo": repo_name,
"path": rel_wf,
"ref": default_ref,
"parameters": params,
}
log.info("GHA '%s': %d reusable workflows found", org, len(results))
return results
# ── Main ─────────────────────────────────────────────────────────────────────
def build_catalog(
sources_file: pathlib.Path,
mode: str | None,
) -> dict[str, Any]:
config = yaml.safe_load(sources_file.read_text(encoding="utf-8"))
templates: dict[str, dict[str, Any]] = {}
if mode in (None, "azdo"):
for src in config.get("sources", {}).get("azdo", []):
templates.update(scan_azdo_source(src))
if mode in (None, "gha"):
for src in config.get("sources", {}).get("gha", []):
templates.update(scan_gha_source(src))
return {
"synced_at": datetime.now(timezone.utc).isoformat(),
"template_count": len(templates),
"templates": templates,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--mode", choices=["azdo", "gha"], default=None, help="Only scan one format")
parser.add_argument("--dry-run", action="store_true", help="Print summary without writing")
parser.add_argument("--output", default=str(_DEFAULT_OUTPUT), help="Output JSON file")
parser.add_argument("--sources", default=str(_SOURCES_FILE), help="Sources YAML config")
args = parser.parse_args()
sources_file = pathlib.Path(args.sources)
if not sources_file.exists():
log.error("Sources file not found: %s", sources_file)
sys.exit(1)
catalog = build_catalog(sources_file, args.mode)
if args.dry_run:
print(f"\n── Pipeline template catalog (dry-run) ──")
print(f" Templates found: {catalog['template_count']}")
for key, tmpl in catalog["templates"].items():
nparams = len(tmpl["parameters"])
required = sum(1 for p in tmpl["parameters"] if p.get("required"))
print(f" [{tmpl['format'].upper()}] {key} ({nparams} params, {required} required)")
return
out = pathlib.Path(args.output)
out.write_text(json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8")
log.info("Written: %s (%d templates)", out, catalog["template_count"])
if __name__ == "__main__":
main()