From 13962fee2e097b9b60bcbaa69b83ed097581b8ae Mon Sep 17 00:00:00 2001 From: David Festal Date: Fri, 8 May 2026 00:36:54 +0200 Subject: [PATCH] feat(overlay): add generate-metadata workflow for Package entity creation Add a 6-phase workflow that generates missing Package metadata files and audits existing ones for consistency. Key capabilities: - Scans workspaces for plugins missing metadata files - Derives deterministic fields (name, OCI URL, supportedVersions) from source.json, plugins-list.yaml, and upstream package.json - Fetches config.d.ts from upstream to generate appConfigExamples - Audits supportedVersions consistency and empty appConfigExamples - Updates smoke-tests/test.env with placeholder variables - Delegates from onboard-plugin Phase 4 Also adds path_resolution and shell_permissions directives to SKILL.md for reliable script invocation across all workflows, and rewrites the metadata-format reference with real examples and correct paths. Co-authored-by: Cursor --- skills/overlay/SKILL.md | 13 +- skills/overlay/references/metadata-format.md | 171 ++++-- skills/overlay/scripts/derive-metadata.py | 554 ++++++++++++++++++ skills/overlay/workflows/generate-metadata.md | 394 +++++++++++++ skills/overlay/workflows/onboard-plugin.md | 38 +- tests/unit/test_derive_metadata.py | 410 +++++++++++++ 6 files changed, 1501 insertions(+), 79 deletions(-) create mode 100644 skills/overlay/scripts/derive-metadata.py create mode 100644 skills/overlay/workflows/generate-metadata.md create mode 100644 tests/unit/test_derive_metadata.py diff --git a/skills/overlay/SKILL.md b/skills/overlay/SKILL.md index bb98262..825f0f7 100644 --- a/skills/overlay/SKILL.md +++ b/skills/overlay/SKILL.md @@ -1,8 +1,16 @@ --- name: overlay -description: Manages the rhdh-plugin-export-overlays repository — onboards plugins to the Extensions Catalog, updates plugin versions, fixes overlay build failures, triages and analyzes PRs, triggers publishes, and manages plugin workspaces. Use when working with overlays, importing plugins, debugging CI, checking PRs, or bumping versions. +description: Manages the rhdh-plugin-export-overlays repository — onboards plugins to the Extensions Catalog, updates plugin versions, generates and audits Package metadata, fixes overlay build failures, triages and analyzes PRs, triggers publishes, and manages plugin workspaces. Use when working with overlays, importing plugins, generating metadata, auditing metadata, fixing metadata inconsistencies, debugging CI, checking PRs, or bumping versions. --- + +All relative paths in this skill (`scripts/`, `references/`, `workflows/`, `templates/`) are relative to **this SKILL.md file's directory**. Derive the skill root from the absolute path used to read this file. For example, if this file was read from `/Users/me/.agents/skills/overlay/SKILL.md`, then `scripts/derive-metadata.py` resolves to `/Users/me/.agents/skills/overlay/scripts/derive-metadata.py`. Always use absolute paths when invoking scripts or reading reference files. + + + +Prefer running `gh api` and `gh search code` as **direct shell commands** rather than via Python subprocess. Direct `gh` calls go through the user's command allowlist without triggering permission prompts. Python scripts that only do local work (file I/O, JSON processing, field derivation) also need no extra permissions. Only request `full_network` for Python scripts that internally spawn `gh` as a subprocess — the sandbox blocks network access from child processes. + + This skill uses the orchestrator CLI. **Set up first:** @@ -63,6 +71,7 @@ What overlay task would you like to do? 2. **Update plugin version** — Bump to newer upstream commit/tag 3. **Check plugin status** — Verify health and compatibility 4. **Fix build failure** — Debug CI/publish issues +8. **Generate or audit metadata** — Add missing Package metadata or fix inconsistencies in existing metadata ### Core Team Tasks @@ -84,6 +93,7 @@ What overlay task would you like to do? | 2, "update", "bump", "upgrade", "version" | `workflows/update-plugin.md` | | 3, "status", "check", "health" | Run inline status checks | | 4, "fix", "debug", "failure", "error" | `workflows/fix-build.md` | +| 8, "metadata", "generate metadata", "add metadata", "audit", "audit metadata", "fix metadata", "check metadata", "validate metadata" | `workflows/generate-metadata.md` | ### Core Team Routes @@ -158,6 +168,7 @@ See `../rhdh/references/github-reference.md` for full patterns. | onboard-plugin.md | Full 6-phase process to add new plugin | | update-plugin.md | Bump to newer upstream version | | fix-build.md | Debug and resolve CI failures | +| generate-metadata.md | Generate missing Package metadata and audit existing metadata for consistency | ### Core Team Workflows diff --git a/skills/overlay/references/metadata-format.md b/skills/overlay/references/metadata-format.md index 2c3766d..0dbfa72 100644 --- a/skills/overlay/references/metadata-format.md +++ b/skills/overlay/references/metadata-format.md @@ -11,47 +11,139 @@ Two entity kinds work together: **Location:** `workspaces//metadata/.yaml` -**Purpose:** Defines a single exportable package with its configuration. +**Purpose:** Defines a single exportable package with its OCI artifact reference and configuration. + +**Frontend plugin example:** + +```yaml +apiVersion: extensions.backstage.io/v1alpha1 +kind: Package +metadata: + name: backstage-community-plugin-dynatrace + namespace: rhdh + title: "Dynatrace" + links: + - url: https://red.ht/rhdh + title: Homepage + - url: https://github.com/backstage/community-plugins/issues + title: Bugs + - title: Source Code + url: https://github.com/backstage/community-plugins/tree/main/workspaces/dynatrace/plugins/dynatrace + annotations: + backstage.io/source-location: url:https://github.com/backstage/community-plugins/tree/main/workspaces/dynatrace/plugins/dynatrace + tags: [] +spec: + packageName: "@backstage-community/plugin-dynatrace" + dynamicArtifact: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-dynatrace:bs_1.49.4__10.17.0!backstage-community-plugin-dynatrace + version: 10.17.0 + backstage: + role: frontend-plugin + supportedVersions: 1.45.3 + author: Dynatrace + support: community + lifecycle: active + partOf: + - dynatrace-community + appConfigExamples: + - title: Default configuration + content: + dynamicPlugins: + frontend: + backstage-community.plugin-dynatrace: + mountPoints: + - mountPoint: entity.page.monitoring/cards + importName: DynatraceTab + config: + layout: + gridColumn: 1 / -1 + if: + allOf: + - isDynatraceAvailable +``` + +**Backend plugin example (with config):** ```yaml apiVersion: extensions.backstage.io/v1alpha1 kind: Package metadata: - name: # e.g., backstage-plugin-aws-codebuild - title: - description: + name: backstage-community-plugin-redhat-argocd-backend + namespace: rhdh + title: "ArgoCD Backend" + links: + - url: https://red.ht/rhdh + title: Homepage + - url: https://github.com/backstage/community-plugins/issues + title: Bugs + - title: Source Code + url: https://github.com/backstage/community-plugins/tree/main/workspaces/argocd/plugins/argocd-backend + annotations: + backstage.io/source-location: url:https://github.com/backstage/community-plugins/tree/main/workspaces/argocd/plugins/argocd-backend + tags: [] spec: - packageName: # e.g., @aws/backstage-plugin-aws-codebuild - - # Dynamic plugin configuration - dynamicPluginConfig: - frontend: - # or backend: for backend plugins - mountPoints: - - id: entity-card - importName: AwsCodeBuildCard - config: - layout: - gridColumnEnd: span 4 - - # Example app-config.yaml snippets + packageName: "@backstage-community/plugin-argocd-backend" + dynamicArtifact: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd-backend:bs_1.49.4__1.4.0!backstage-community-plugin-argocd-backend + version: 1.4.0 + backstage: + role: backend-plugin + supportedVersions: 1.48.3 + author: Red Hat + support: community + lifecycle: active + partOf: + - redhat-argocd appConfigExamples: - - title: Basic Configuration - content: | - aws: - codebuild: - accountId: '123456789012' - region: us-east-1 + - title: Default configuration + content: + argocd: + username: ${ARGOCD_USERNAME} + password: ${ARGOCD_PASSWORD} + appLocatorMethods: + - type: config + instances: + - name: argoInstance1 + url: ${ARGOCD_INSTANCE1_URL} + token: ${ARGOCD_AUTH_TOKEN} ``` -**Key sections:** +**Backend plugin example (no config):** + +```yaml +spec: + # ... other fields ... + backstage: + role: backend-plugin + supportedVersions: 1.48.3 + appConfigNotRequired: true + appConfigExamples: [] +``` -- `dynamicPluginConfig` — how the plugin mounts in RHDH -- `appConfigExamples` — configuration snippets for users +**Key fields:** + +| Field | Source | Required | +|-------|--------|----------| +| `metadata.name` | Derived from `packageName`: strip `@`, replace `/` with `-`. Shorten if >63 chars. | Yes | +| `metadata.namespace` | Always `rhdh` | Yes | +| `spec.packageName` | npm package name from upstream `package.json` | Yes | +| `spec.dynamicArtifact` | OCI URL: `oci://ghcr.io/.../name:bs___!name` | Yes | +| `spec.version` | From upstream `package.json` | Yes | +| `spec.backstage.role` | `frontend-plugin` or `backend-plugin` from upstream `package.json` | Yes | +| `spec.backstage.supportedVersions` | From overlay `backstage.json` override, or `source.json` `repo-backstage-version` | Yes | +| `spec.author` | From upstream `package.json` or copy from existing workspace metadata | Yes | +| `spec.support` | Typically `community` for new plugins | Yes | +| `spec.lifecycle` | Typically `active` | Yes | +| `spec.partOf` | References the Plugin entity `metadata.name` | Yes | +| `spec.appConfigNotRequired` | `true` when no config schema exists (backend only) | Conditional | +| `spec.appConfigExamples` | Config examples derived from `config.d.ts` + frontend wiring | Yes | + +**Name shortening (when >63 chars):** + +Apply rules from [shorten-component-name.sh](https://github.com/redhat-developer/rhdh-plugin-export-utils/blob/main/common/scripts/shorten-component-name.sh): +`backstage-community-plugin` → `bcp`, `backstage-plugin` → `bsp`, `red-hat-developer-hub-` → `rhdh-`, `catalog` → `ctlg`, `module` → `mod`, `kubernetes` → `k8s`, `bitbucket` → `bbckt` -**Location:** `catalog-entities/marketplace/plugins/.yaml` +**Location:** `catalog-entities/extensions/plugins/.yaml` **Purpose:** User-facing catalog entry that groups related packages. @@ -59,13 +151,13 @@ spec: apiVersion: extensions.backstage.io/v1alpha1 kind: Plugin metadata: - name: # e.g., aws-codebuild + name: + namespace: rhdh title: description: annotations: extensions.backstage.io/icon: spec: - # Full markdown documentation description: | ## Overview @@ -76,32 +168,23 @@ spec: ## Configuration - - # Packages included in this plugin packages: - - backstage-plugin-aws-codebuild - - backstage-plugin-aws-codebuild-backend - - # For filtering in catalog + - + - categories: - CI/CD - Cloud - - # Feature highlights highlights: - Build status visibility - Project history - - Start/stop builds - - # Support level - developer: AWS + developer: supportLevel: community ``` **Registration:** -Add to `catalog-entities/marketplace/plugins/all.yaml` (alphabetical order). +Add to `catalog-entities/extensions/plugins/all.yaml` (alphabetical order). -Full annotated example: [catalog-entities/marketplace/README.md](https://github.com/redhat-developer/rhdh-plugin-export-overlays/blob/main/catalog-entities/marketplace/README.md) +Full annotated example: [catalog-entities/extensions/README.md](https://github.com/redhat-developer/rhdh-plugin-export-overlays/blob/main/catalog-entities/extensions/README.md) diff --git a/skills/overlay/scripts/derive-metadata.py b/skills/overlay/scripts/derive-metadata.py new file mode 100644 index 0000000..5469b6f --- /dev/null +++ b/skills/overlay/scripts/derive-metadata.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python3 +"""Derive Package metadata fields from workspace configuration and upstream package.json. + +Handles deterministic field derivation: metadata.name (with shortening), +dynamicArtifact OCI URL, links, annotations, supportedVersions, and +smoke test env var extraction. Outputs structured JSON for the agent to +consume when assembling Package YAML files. + +Usage: + python scripts/derive-metadata.py --workspace argocd + python scripts/derive-metadata.py --workspace argocd --package-json '{"name":"@backstage-community/plugin-argocd","version":"2.8.0","backstage":{"role":"frontend-plugin"}}' + python scripts/derive-metadata.py --extract-env-vars metadata-file.yaml +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +K8S_NAME_LIMIT = 63 + +SHORTEN_RULES = [ + ("rhdh-plugin-catalog--", ""), + ("red-hat-developer-hub-", "rhdh-"), + ("backstage-community-plugin", "bcp"), + ("backstage-plugin", "bsp"), + ("backstage", "bs"), + ("plugin", "plgn"), + ("catalog", "ctlg"), + ("module", "mod"), + ("kubernetes", "k8s"), + ("bitbucket", "bbckt"), + ("parfuemerie-douglas", "parfdg"), +] + +OCI_REGISTRY = "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays" + + +def shorten_name(name: str) -> str: + """Apply shortening rules only if name exceeds K8S_NAME_LIMIT.""" + if len(name) <= K8S_NAME_LIMIT: + return name + shortened = name + for old, new in SHORTEN_RULES: + shortened = shortened.replace(old, new) + return shortened + + +def package_name_to_metadata_name(package_name: str) -> str: + """Derive metadata.name from npm package name.""" + name = package_name.lstrip("@").replace("/", "-") + return shorten_name(name) + + +def derive_title(package_name: str) -> str: + """Derive a human-readable title from package name.""" + name = package_name.split("/")[-1] if "/" in package_name else package_name + name = name.removeprefix("plugin-").removeprefix("backstage-plugin-") + parts = name.split("-") + return " ".join(p.capitalize() for p in parts) + + +def derive_source_code_url( + repo: str, workspace: str, plugin_path: str, flat: bool +) -> str: + """Derive Source Code URL from repo, workspace, plugin path, and flat flag.""" + base = f"{repo}/tree/main" + if flat: + if plugin_path == ".": + return base + return f"{base}/{plugin_path}" + if plugin_path == ".": + return f"{base}/workspaces/{workspace}" + return f"{base}/workspaces/{workspace}/{plugin_path}" + + +def derive_oci_url(metadata_name: str, supported_versions: str, version: str) -> str: + """Derive the dynamicArtifact OCI URL.""" + tag = f"bs_{supported_versions}__{version}" + return f"{OCI_REGISTRY}/{metadata_name}:{tag}!{metadata_name}" + + +def derive_supported_versions(workspace_dir: Path, source: dict) -> str: + """Derive supportedVersions from backstage.json override or source.json.""" + bs_json = workspace_dir / "backstage.json" + if bs_json.exists(): + data = json.loads(bs_json.read_text()) + return data.get("version", source["repo-backstage-version"]) + return source["repo-backstage-version"] + + +def parse_plugins_list(workspace_dir: Path) -> list[dict]: + """Parse plugins-list.yaml and return list of plugin entries. + + Format: each top-level line is ``:`` or ``: ``. + Indented lines (`` - ...``) are CLI arg continuations and are skipped. + """ + plugins_list = workspace_dir / "plugins-list.yaml" + content = plugins_list.read_text() + plugins = [] + for line in content.splitlines(): + stripped = line.rstrip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith(" "): + continue + if "#" in stripped: + stripped = stripped[:stripped.index("#")].rstrip() + path = stripped.split(":")[0].strip().removeprefix("- ") + if path: + plugins.append({"path": path}) + return plugins + + +def find_missing_metadata(workspace_dir: Path, plugins: list[dict]) -> list[dict]: + """Identify plugins that lack metadata files. + + Uses a heuristic: for each plugin path, check if any existing metadata file's + packageName corresponds to that path. Falls back to filename pattern matching. + """ + metadata_dir = workspace_dir / "metadata" + existing_files = list(metadata_dir.glob("*.yaml")) if metadata_dir.exists() else [] + existing_names = {f.stem for f in existing_files} + + missing = [] + for plugin in plugins: + path = plugin["path"] + path_suffix = path.rstrip("/").split("/")[-1] if path != "." else "" + found = any(path_suffix and path_suffix in name for name in existing_names) + if not found and path != ".": + missing.append(plugin) + elif path == "." and not existing_names: + missing.append(plugin) + return missing + + +def extract_env_vars(yaml_content: str) -> list[str]: + """Extract ${VAR_NAME} references from YAML content.""" + return sorted(set(re.findall(r"\$\{([A-Z_][A-Z0-9_]*)\}", yaml_content))) + + +def read_existing_metadata(workspace_dir: Path) -> dict | None: + """Read first existing metadata file and extract copyable fields.""" + metadata_dir = workspace_dir / "metadata" + if not metadata_dir.exists(): + return None + files = sorted(metadata_dir.glob("*.yaml")) + if not files: + return None + + content = files[0].read_text() + result = {} + for field, pattern in [ + ("author", r"^\s+author:\s+(.+)$"), + ("support", r"^\s+support:\s+(.+)$"), + ("lifecycle", r"^\s+lifecycle:\s+(.+)$"), + ("supportedVersions", r"^\s+supportedVersions:\s+(.+)$"), + ]: + match = re.search(pattern, content, re.MULTILINE) + if match: + result[field] = match.group(1).strip().strip('"').strip("'") + + part_of = [] + in_part_of = False + for line in content.splitlines(): + if "partOf:" in line: + in_part_of = True + continue + if in_part_of: + stripped = line.strip() + if stripped.startswith("- "): + part_of.append(stripped[2:].strip()) + else: + break + if part_of: + result["partOf"] = part_of + + return result if result else None + + +def check_supported_versions_consistency( + workspace_dir: Path, expected: str +) -> list[dict]: + """Check all metadata files for supportedVersions mismatches.""" + metadata_dir = workspace_dir / "metadata" + if not metadata_dir.exists(): + return [] + mismatches = [] + for f in sorted(metadata_dir.glob("*.yaml")): + content = f.read_text() + match = re.search(r"^\s+supportedVersions:\s+(.+)$", content, re.MULTILINE) + if match: + actual = match.group(1).strip().strip('"').strip("'") + if actual != expected: + mismatches.append( + {"file": f.name, "actual": actual, "expected": expected} + ) + return mismatches + + +def check_empty_config_without_flag(workspace_dir: Path) -> list[str]: + """Find metadata files with appConfigExamples: [] but no appConfigNotRequired.""" + metadata_dir = workspace_dir / "metadata" + if not metadata_dir.exists(): + return [] + issues = [] + for f in sorted(metadata_dir.glob("*.yaml")): + content = f.read_text() + if "appConfigExamples: []" in content and "appConfigNotRequired:" not in content: + issues.append(f.name) + return issues + + +def derive_plugin_fields( + package_json: dict, + workspace: str, + plugin_path: str, + source: dict, + supported_versions: str, + existing: dict | None, +) -> dict: + """Derive all metadata fields for a single plugin.""" + pkg_name = package_json["name"] + version = package_json["version"] + role = package_json.get("backstage", {}).get("role", "") + if not role: + role = "backend-plugin" if plugin_path.endswith("-backend") or "-backend" in plugin_path else "frontend-plugin" + + metadata_name = package_name_to_metadata_name(pkg_name) + title = derive_title(pkg_name) + repo = source["repo"] + flat = source.get("repo-flat", False) + source_url = derive_source_code_url(repo, workspace, plugin_path, flat) + oci_url = derive_oci_url(metadata_name, supported_versions, version) + + result = { + "metadata_name": metadata_name, + "filename": f"{metadata_name}.yaml", + "title": title, + "packageName": pkg_name, + "version": version, + "role": role, + "dynamicArtifact": oci_url, + "supportedVersions": supported_versions, + "sourceCodeUrl": source_url, + "bugsUrl": f"{repo}/issues", + "plugin_path": plugin_path, + } + + if existing: + for key in ("author", "support", "lifecycle", "partOf"): + if key in existing: + result[key] = existing[key] + + result.setdefault("support", "community") + + if result["support"] in ("generally-available", "tech-preview"): + result["support_needs_confirmation"] = True + + return result + + +def run_gh(args, check=True): + """Run a gh CLI command and return stdout as string.""" + cmd = ["gh"] + args + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=check, timeout=30) + return result.stdout.strip() + except subprocess.CalledProcessError as exc: + print(f"Error running: {' '.join(cmd)}", file=sys.stderr) + if exc.stderr: + print(exc.stderr, file=sys.stderr) + if check: + raise + return None + + +def gh_fetch_file(owner_repo: str, path: str, ref: str) -> str | None: + """Fetch a file from GitHub at a specific ref. Returns decoded content or None.""" + encoded = path.replace(" ", "%20") + output = run_gh( + ["api", f"repos/{owner_repo}/contents/{encoded}?ref={ref}", "--jq", ".content"], + check=False, + ) + if not output: + return None + try: + return base64.b64decode(output).decode("utf-8") + except Exception: + return None + + +def gh_list_dir(owner_repo: str, path: str, ref: str) -> list[str]: + """List file names in a GitHub directory at a specific ref.""" + output = run_gh( + ["api", f"repos/{owner_repo}/contents/{path}?ref={ref}", "--jq", ".[].name"], + check=False, + ) + if not output: + return [] + return output.splitlines() + + +def fetch_and_derive_all( + workspace_dir: Path, workspace: str, source: dict, missing_paths: list[str] +) -> dict: + """Fetch package.json for all missing plugins via gh api and derive fields. + + Returns a dict with 'plugins' (derived fields per plugin), + 'errors' (plugins that failed to fetch), and 'config_files' (config.d.ts + content for plugins that have one). + """ + repo = source["repo"] + owner_repo = repo.replace("https://github.com/", "") + ref = source["repo-ref"] + flat = source.get("repo-flat", False) + supported_versions = derive_supported_versions(workspace_dir, source) + existing = read_existing_metadata(workspace_dir) + + results = {"plugins": [], "errors": [], "config_files": {}} + + for plugin_path in missing_paths: + if flat: + src_path = "" if plugin_path == "." else plugin_path + else: + src_path = ( + f"workspaces/{workspace}" + if plugin_path == "." + else f"workspaces/{workspace}/{plugin_path}" + ) + + pkg_path = f"{src_path}/package.json" if src_path else "package.json" + raw = gh_fetch_file(owner_repo, pkg_path, ref) + if not raw: + results["errors"].append( + {"plugin_path": plugin_path, "error": f"Failed to fetch {pkg_path}"} + ) + continue + + try: + pkg = json.loads(raw) + except json.JSONDecodeError as e: + results["errors"].append( + {"plugin_path": plugin_path, "error": f"Invalid JSON in package.json: {e}"} + ) + continue + + fields = derive_plugin_fields( + pkg, workspace, plugin_path, source, supported_versions, existing + ) + results["plugins"].append(fields) + + config_path = f"{src_path}/config.d.ts" if src_path else "config.d.ts" + config_content = gh_fetch_file(owner_repo, config_path, ref) + if config_content: + results["config_files"][plugin_path] = config_content + + dir_path = f"{src_path}/src" if src_path else "src" + src_files = gh_list_dir(owner_repo, dir_path, ref) + has_plugin_ts = any(f in src_files for f in ("plugin.ts", "plugin.tsx")) + has_alpha_ts = "alpha.ts" in src_files + fields["has_plugin_ts"] = has_plugin_ts + fields["has_alpha_ts"] = has_alpha_ts + + return results + + +def main(): + parser = argparse.ArgumentParser( + description="Derive Package metadata fields for overlay plugins.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="command") + + derive_cmd = sub.add_parser( + "derive", + help="Derive metadata fields for a plugin from its package.json", + ) + derive_cmd.add_argument("--workspace", required=True, help="Workspace name") + derive_cmd.add_argument( + "--package-json", + help="package.json content as JSON string (if not provided, derives workspace-level info only)", + ) + derive_cmd.add_argument("--plugin-path", default=".", help="Plugin path within workspace") + derive_cmd.add_argument( + "--overlay-dir", + default=".", + help="Path to overlay repo root", + ) + + scan_cmd = sub.add_parser( + "scan", + help="Scan workspace for missing metadata and consistency issues", + ) + scan_cmd.add_argument("--workspace", required=True, help="Workspace name") + scan_cmd.add_argument( + "--overlay-dir", + default=".", + help="Path to overlay repo root", + ) + + fetch_cmd = sub.add_parser( + "fetch-and-derive", + help="Scan, fetch upstream package.json via gh api, and derive all fields in one shot", + ) + fetch_cmd.add_argument("--workspace", required=True, help="Workspace name") + fetch_cmd.add_argument( + "--overlay-dir", + default=".", + help="Path to overlay repo root", + ) + + env_cmd = sub.add_parser( + "extract-env-vars", + help="Extract ${VAR} references from a YAML file", + ) + env_cmd.add_argument("file", help="Path to YAML file") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + sys.exit(1) + + is_tty = os.isatty(sys.stdout.fileno()) + + if args.command == "extract-env-vars": + content = Path(args.file).read_text() + env_vars = extract_env_vars(content) + output = {"env_vars": env_vars} + print(json.dumps(output, indent=2 if is_tty else None)) + return + + overlay_dir = Path(args.overlay_dir) + workspace_dir = overlay_dir / "workspaces" / args.workspace + + if not workspace_dir.exists(): + print(json.dumps({"error": f"Workspace not found: {workspace_dir}"}), file=sys.stderr) + sys.exit(1) + + source_json = workspace_dir / "source.json" + if not source_json.exists(): + print(json.dumps({"error": "source.json not found"}), file=sys.stderr) + sys.exit(1) + + source = json.loads(source_json.read_text()) + + if args.command == "scan": + plugins = parse_plugins_list(workspace_dir) + missing = find_missing_metadata(workspace_dir, plugins) + supported_versions = derive_supported_versions(workspace_dir, source) + version_mismatches = check_supported_versions_consistency( + workspace_dir, supported_versions + ) + empty_config_issues = check_empty_config_without_flag(workspace_dir) + existing = read_existing_metadata(workspace_dir) + + repo = source["repo"] + owner_repo = repo.replace("https://github.com/", "") + flat = source.get("repo-flat", False) + + output = { + "workspace": args.workspace, + "repo": repo, + "owner_repo": owner_repo, + "repo_ref": source["repo-ref"], + "repo_flat": flat, + "supported_versions": supported_versions, + "total_plugins": len(plugins), + "missing_metadata": [p["path"] for p in missing], + "missing_count": len(missing), + "existing_metadata": existing, + "version_mismatches": version_mismatches, + "empty_config_issues": empty_config_issues, + "source_paths": {}, + } + for plugin in plugins: + path = plugin["path"] + if flat: + src_path = "" if path == "." else path + else: + src_path = f"workspaces/{args.workspace}" if path == "." else f"workspaces/{args.workspace}/{path}" + output["source_paths"][path] = src_path + + print(json.dumps(output, indent=2 if is_tty else None)) + return + + if args.command == "fetch-and-derive": + plugins = parse_plugins_list(workspace_dir) + missing = find_missing_metadata(workspace_dir, plugins) + supported_versions = derive_supported_versions(workspace_dir, source) + version_mismatches = check_supported_versions_consistency( + workspace_dir, supported_versions + ) + empty_config_issues = check_empty_config_without_flag(workspace_dir) + existing = read_existing_metadata(workspace_dir) + + if not missing: + output = { + "workspace": args.workspace, + "missing_count": 0, + "message": "All plugins already have metadata", + "version_mismatches": version_mismatches, + "empty_config_issues": empty_config_issues, + "existing_metadata": existing, + "supported_versions": supported_versions, + } + print(json.dumps(output, indent=2 if is_tty else None)) + return + + missing_paths = [p["path"] for p in missing] + results = fetch_and_derive_all(workspace_dir, args.workspace, source, missing_paths) + + output = { + "workspace": args.workspace, + "supported_versions": supported_versions, + "missing_count": len(missing_paths), + "existing_metadata": existing, + "version_mismatches": version_mismatches, + "empty_config_issues": empty_config_issues, + **results, + } + print(json.dumps(output, indent=2 if is_tty else None)) + return + + if args.command == "derive": + supported_versions = derive_supported_versions(workspace_dir, source) + existing = read_existing_metadata(workspace_dir) + + if args.package_json: + pkg = json.loads(args.package_json) + fields = derive_plugin_fields( + pkg, args.workspace, args.plugin_path, source, + supported_versions, existing, + ) + print(json.dumps(fields, indent=2 if is_tty else None)) + else: + output = { + "workspace": args.workspace, + "supported_versions": supported_versions, + "existing_metadata": existing, + "source": source, + } + print(json.dumps(output, indent=2 if is_tty else None)) + + +if __name__ == "__main__": + main() diff --git a/skills/overlay/workflows/generate-metadata.md b/skills/overlay/workflows/generate-metadata.md new file mode 100644 index 0000000..c640556 --- /dev/null +++ b/skills/overlay/workflows/generate-metadata.md @@ -0,0 +1,394 @@ +# Workflow: Generate and Audit Package Metadata + +Generate missing `kind: Package` metadata YAML files for plugins in an overlay workspace, and audit existing metadata for consistency issues. + + +**Read these reference files NOW:** + +1. `references/metadata-format.md` — Package and Plugin entity structure +2. `references/overlay-repo.md` — Workspace patterns and file layout + + + +| Requirement | Details | +|-------------|---------| +| **Overlay repo** | Local checkout of [rhdh-plugin-export-overlays](https://github.com/redhat-developer/rhdh-plugin-export-overlays) | +| **Tools** | `gh` CLI (authenticated with GitHub) | +| **Script** | `scripts/derive-metadata.py` — handles deterministic field derivation, workspace scanning, and env var extraction | + + + + +## Phase 1: Workspace Identification + +If the workspace name is already known from context (e.g., called from `onboard-plugin.md` Phase 4), use it. + +Otherwise, ask the user which workspace to operate on. + +Confirm the workspace exists: + +```bash +ls workspaces//source.json workspaces//plugins-list.yaml +``` + +--- + +## Phase 2: Scan for Missing Metadata + +Run the scan command from the overlay repo root (no network needed): + +```bash +python3 scripts/derive-metadata.py scan --workspace +``` + +This returns JSON with: `missing_metadata` (plugin paths without metadata files), `version_mismatches` (existing files with stale `supportedVersions`), `empty_config_issues` (files with `appConfigExamples: []` but no `appConfigNotRequired`), `existing_metadata` (copyable fields from existing files), and `source_paths` (upstream paths for GitHub API calls). + +If `missing_count` is 0, report "All plugins already have metadata — no new files needed." Skip Phases 3–5 and proceed directly to Phase 6.3 (consistency checks using the scan output). + +Otherwise, list the missing plugins and proceed. + +--- + +## Phase 3: Fetch Upstream Source and Derive Fields + +For each missing plugin, fetch its `package.json` and optionally `config.d.ts` from the upstream repo using direct `gh api` calls (these go through the user's command allowlist — no sandbox permission needed), then run the derive script to compute all metadata fields. + +### 3.1 Fetch and derive per plugin + +For each plugin path in `missing_metadata`, use the corresponding entry from `source_paths` and the `repo_ref` and `owner_repo` from the scan output: + +**Fetch package.json:** + +```bash +gh api "repos//contents//package.json?ref=" --jq '.content' | base64 -d +``` + +**Derive metadata fields** (pass the fetched JSON to the script — no network needed): + +```bash +python3 scripts/derive-metadata.py derive \ + --workspace \ + --plugin-path \ + --package-json '' +``` + +This returns JSON with all deterministic fields: `metadata_name`, `filename`, `title`, `packageName`, `version`, `role`, `dynamicArtifact`, `supportedVersions`, `sourceCodeUrl`, `bugsUrl`, plus `author`, `support`, `lifecycle`, `partOf` copied from existing metadata (if any). + +The script handles: +- **Name derivation**: strips `@`, replaces `/` with `-`, shortens if >63 chars using [shorten-component-name.sh](https://github.com/redhat-developer/rhdh-plugin-export-utils/blob/main/common/scripts/shorten-component-name.sh) rules +- **OCI URL**: `oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/:bs___!` +- **Links/annotations**: Source Code URL respects `repo-flat` and plugin path (handles `.` for root-level plugins) +- **supportedVersions**: from `backstage.json` override or `source.json` +- **Existing metadata fields**: copies `author`, `support`, `lifecycle`, `partOf` from existing files +- **Support level**: if the derived `support` is `generally-available` or `tech-preview` (copied from existing metadata), the script sets `support_needs_confirmation: true`. Ask the user: "Existing metadata uses ``. Apply the same tier to the new package, or default to `community`?" If the context does not allow asking, default to `community`. + +### 3.2 Fetch config.d.ts (if present) + +```bash +gh api "repos//contents//config.d.ts?ref=" --jq '.content' | base64 -d +``` + +If this returns a 404, the plugin has no config schema — that's fine. + +### 3.3 Check for frontend source files + +```bash +gh api "repos//contents//src?ref=" --jq '.[].name' +``` + +Note whether `plugin.ts`, `plugin.tsx`, or `alpha.ts` are present — needed for Phase 4 frontend wiring. + +**`supportedVersions` mismatches** were already detected in Phase 2 scan output (`version_mismatches`). Use the correct derived value for new metadata and carry the mismatch list to Phase 5 + +--- + +## Phase 4: Analyze Config and Wiring Per Plugin + +For each plugin, analyze its configuration schema and frontend wiring using the data fetched in Phase 3. + +### 4a. Analyze config schema + +Use the `config.d.ts` content fetched in Phase 3.2 (if present). + +**If `config.d.ts` content was fetched** for this plugin: + +1. Parse the TypeScript `Config` interface to extract: + - Config key paths (e.g., `argocd.baseUrl`, `argocd.appLocatorMethods[].instances[].url`) + - Types (string, boolean, number, array, object) + - `@visibility` annotations (`frontend`, `backend`, `secret`) + - JSDoc descriptions + +2. If more source context is needed to validate config usage, fetch individual files via `gh api` as needed (this may require one additional authorization). + +3. Generate `appConfigExamples` content using placeholder values: + - `@visibility secret` fields → `${UPPER_SNAKE_CASE}` env var placeholder (e.g., `${ARGOCD_PASSWORD}`) + - String fields → realistic placeholder URL or name (e.g., `https://argocd.example.com`) + - Boolean fields → `true` or `false` + - Number fields → sensible default from JSDoc or `0` + - Array fields → one example entry + +**If NO `config.d.ts` content is present:** + +- For **backend plugins**: set `appConfigNotRequired: true` and `appConfigExamples: []` +- For **frontend plugins**: `appConfigExamples` will contain only the wiring section from 4b + +### 4b. Frontend wiring (frontend plugins only) + +Use the `plugin.ts`/`alpha.ts` presence from Phase 3.3 to determine which source files to analyze. If needed, fetch them via `gh api`: + +```bash +gh api repos///contents//src/plugin.ts?ref= --jq '.content' | base64 -d +``` + +Alternatively, delegate to the **`generate-frontend-wiring` skill** to analyze: +- `src/plugin.ts` / `src/plugin.tsx` — `createRoutableExtension`, `createComponentExtension` calls +- `src/alpha.ts` — new frontend system extensions +- `src/index.ts` — public exports + +Merge the resulting `dynamicPlugins.frontend.` section into `appConfigExamples`: + +```yaml +appConfigExamples: + - title: Default configuration + content: + dynamicPlugins: + frontend: + : + mountPoints: + - mountPoint: + importName: + config: + layout: + gridColumn: 1 / -1 +``` + +If both wiring AND config schema exist for a frontend plugin, combine them into a single `appConfigExamples` entry — the `dynamicPlugins` wiring section plus the app-config keys from 4a. + +--- + +## Phase 5: Plugin Entity Resolution + +### 5a. Existing metadata exists in the workspace + +Extract `partOf` values from existing Package YAML files. Use the same `partOf` for all new packages. + +### 5b. No existing metadata — called from onboard-plugin + +Create a Plugin entity at `catalog-entities/extensions/plugins/.yaml`: + +```yaml +apiVersion: extensions.backstage.io/v1alpha1 +kind: Plugin +metadata: + name: + namespace: rhdh + title: + description: +spec: + description: | + ## Overview + + + ## Features + - + + ## Configuration + + packages: + - + - + categories: + - + highlights: + - + developer: + supportLevel: community +``` + +Also add the file to `catalog-entities/extensions/plugins/all.yaml` in alphabetical order. + +Set `partOf` for all Package entities to the Plugin entity's `metadata.name`. + +### 5c. No existing metadata — called standalone + +Ask the user: "No Plugin entity found for this workspace. Do you want to create one?" + +- If yes: follow 5b +- If no: leave `partOf` empty + +--- + +## Phase 6: Write Files and Report + +### 6.1 Assemble and write Package YAML files + +For each plugin, assemble the full Package YAML: + +```yaml +apiVersion: extensions.backstage.io/v1alpha1 +kind: Package +metadata: + name: + namespace: rhdh + title: "" + links: + - url: https://red.ht/rhdh + title: Homepage + - url: <repo>/issues + title: Bugs + - title: Source Code + url: <source-code-url> + annotations: + backstage.io/source-location: url:<source-code-url> + tags: [] +spec: + packageName: "<npm-package-name>" + dynamicArtifact: <oci-url> + version: <version> + backstage: + role: <role> + supportedVersions: <supported-versions> + author: <author> + support: <support> + lifecycle: <lifecycle> + partOf: + - <plugin-entity-name> + appConfigNotRequired: <true if backend with no config, omit otherwise> + appConfigExamples: + <generated examples or []> +``` + +Write to `workspaces/<workspace>/metadata/<metadata-name>.yaml`. + +### 6.2 Update smoke test environment variables + +For each generated metadata file, extract env var references: + +```bash +python3 scripts/derive-metadata.py extract-env-vars workspaces/<workspace>/metadata/<filename>.yaml +``` + +If any `${VAR_NAME}` env var placeholders are found, ensure they have corresponding entries in `workspaces/<workspace>/smoke-tests/test.env`. + +**If `smoke-tests/test.env` exists:** read it, identify any `${VAR_NAME}` references from the new metadata that are missing from the file, and append them with dummy placeholder values: +- Secret/token fields → `dummy-smoke-test-secret` +- URL fields → `https://smoke-test.example.com/<service>` +- Username fields → `dummyAdmin` +- Password fields → `dummyPassword` +- Other string fields → `dummy-value` + +**If `smoke-tests/test.env` does not exist:** create it with the standard header and all required env vars: + +``` +# Smoke-test placeholders for dynamic plugin config substitution. +# Values are non-production dummies for CI/local smoke runs only. +# Existing entries win over generated defaults; re-run to add missing keys. + +VAR_NAME=dummy-value +``` + +Preserve any existing entries — never overwrite values that are already set. + +### 6.3 Audit existing metadata and present summary + +This phase always runs — even when no new files were generated (audit-only mode). + +**If new files were generated,** show a summary table: + +| File | Package Name | Role | Config Found | Wiring | +|------|-------------|------|-------------|--------| +| `<filename>` | `<packageName>` | `<role>` | yes/no (N keys) | N mount points / N/A | + +If a Plugin entity was created, note it separately. + +**Then run the following consistency checks on ALL metadata files in the workspace (both new and pre-existing):** + +#### Check: `supportedVersions` consistency + +Derive the expected `supportedVersions` value: +1. If `workspaces/<workspace>/backstage.json` exists, use its `version` field +2. Otherwise, use `repo-backstage-version` from `source.json` + +Compare against the `spec.backstage.supportedVersions` in every metadata file. If any files have a stale or incorrect value, list them and ask the user: + +> "The following metadata files have `supportedVersions: <old>` but the expected value is `<new>`. Do you want to fix them?" + +If the user answers yes, update the `supportedVersions` field in those files. If no, leave them unchanged. + +#### Check: `appConfigExamples: []` without `appConfigNotRequired` + +Scan all metadata files for files that have `appConfigExamples: []` but are missing `spec.appConfigNotRequired: true`. This combination fails publish validation — an empty `appConfigExamples` is only valid when `appConfigNotRequired: true` is explicitly set. If any files match, list them and ask: + +> "The following metadata files have empty `appConfigExamples` but are missing `appConfigNotRequired: true`. Do you want to fix them?" + +If the user answers yes, add `appConfigNotRequired: true` above `appConfigExamples: []` in those files. If no, leave them unchanged. + +#### Summary + +If all checks pass with no issues found, report: "All existing metadata files are consistent — no fixes needed." + +### 6.4 Propose commit message and PR description + +After all questions have been asked and answered, propose: + +**Commit message** (concise, following repo conventions): + +When new files were generated: + +``` +Add metadata for <plugin-name(s)> in <workspace> workspace +``` + +When new files were generated AND existing files were fixed: + +``` +Add metadata for <plugin-name(s)> in <workspace> workspace + +Also fix <description of fixes> in existing metadata files. +``` + +When only existing files were fixed (audit-only mode): + +``` +Fix metadata inconsistencies in <workspace> workspace +``` + +**PR description** (concise and conformant): + +```markdown +## Summary +- <If applicable: Adds Package metadata for N plugin(s) in the `<workspace>` workspace> +- <If applicable: Creates Plugin entity for `<workspace>`> +- <If applicable: Fixes `supportedVersions` in N existing metadata files> +- <If applicable: Adds missing `appConfigNotRequired` to N existing metadata files> +- <If applicable: Adds smoke test env vars to `test.env`> + +## Generated files +<list of created/modified files> + +## Checklist +- [ ] Package metadata reviewed +- [ ] `appConfigExamples` verified against upstream config schema +- [ ] Smoke test env vars present for all `${VAR}` references +- [ ] `/publish` triggered and successful +``` + +**Disclaimer:** Review the generated files before committing. Pay special attention to `appConfigExamples` — the config schema analysis may need manual refinement. + +</process> + +<success_criteria> +This workflow is complete when: + +- [ ] All plugins in the workspace have corresponding Package metadata YAML files +- [ ] Each Package YAML has correct `packageName`, `version`, `backstage.role` +- [ ] `appConfigExamples` are populated from config schema analysis (or marked `appConfigNotRequired`) +- [ ] Frontend plugins have `dynamicPlugins` wiring in `appConfigExamples` +- [ ] `metadata.links` and `annotations` point to correct source locations +- [ ] `dynamicArtifact` OCI URL follows the naming convention +- [ ] `metadata.name` respects the 63-character Kubernetes limit (shortened if needed) +- [ ] `partOf` references a valid Plugin entity +- [ ] `smoke-tests/test.env` contains dummy values for all `${VAR}` references in `appConfigExamples` +- [ ] No files have `appConfigExamples: []` without `appConfigNotRequired: true` +- [ ] User has reviewed all generated files +</success_criteria> diff --git a/skills/overlay/workflows/onboard-plugin.md b/skills/overlay/workflows/onboard-plugin.md index 2c22fdf..bffdc31 100644 --- a/skills/overlay/workflows/onboard-plugin.md +++ b/skills/overlay/workflows/onboard-plugin.md @@ -234,43 +234,13 @@ gh pr create \ **Goal:** Create metadata files for integration tests and catalog registration. -### 4.1 Create Package Metadata Files +### 4.1 Generate Package and Plugin Metadata -Create one YAML file per exported plugin in `workspaces/<name>/metadata/`. +Follow `workflows/generate-metadata.md` to generate Package metadata (and Plugin entity if needed) for all plugins in the workspace. -**Kind:** `Package` — represents a single npm package (frontend or backend) +Review the generated files before proceeding. -**Documentation:** [catalog-entities/marketplace/README.md](https://github.com/redhat-developer/rhdh-plugin-export-overlays/blob/main/catalog-entities/marketplace/README.md) - -### 4.2 Create Plugin Entity - -Create a Plugin entity that groups your packages together. - -**Kind:** `Plugin` — user-facing catalog entry - -**Location:** `catalog-entities/marketplace/plugins/<plugin-name>.yaml` - -**Key fields:** - -- `metadata.name` — short identifier -- `metadata.description` — brief summary -- `spec.description` — full markdown documentation -- `spec.packages` — list of Package names -- `spec.categories` — for filtering (e.g., `CI/CD`, `Cloud`) - -### 4.3 Trigger Build & Tests - -```bash -git add workspaces/<name>/metadata/ catalog-entities/marketplace/plugins/<name>.yaml -git commit -m "Add plugin metadata" -git push -``` - -Comment `/publish` to rebuild. Watch for test workflow results. - -> ⚠️ **Smoke test only:** The CI test verifies plugins install and RHDH starts without errors. It does **not** test plugin functionality (no API calls, no browser tests). Functional verification happens in Phase 5. - -### 4.4 Add to Packages List (if applicable) +### 4.2 Add to Packages List (if applicable) Plugins in these lists become "required" — release gates fail if they're incompatible. diff --git a/tests/unit/test_derive_metadata.py b/tests/unit/test_derive_metadata.py new file mode 100644 index 0000000..77c3b21 --- /dev/null +++ b/tests/unit/test_derive_metadata.py @@ -0,0 +1,410 @@ +"""Tests for overlay skill's derive-metadata.py script.""" + +import importlib.util +import json +import textwrap +from pathlib import Path + +import pytest + +# Load derive-metadata.py as a module (hyphenated filename can't be imported normally) +SCRIPT_PATH = Path(__file__).parent.parent.parent / "skills" / "overlay" / "scripts" / "derive-metadata.py" +spec = importlib.util.spec_from_file_location("derive_metadata", SCRIPT_PATH) +dm = importlib.util.module_from_spec(spec) +spec.loader.exec_module(dm) + + +class TestParsePluginsList: + """Test plugins-list.yaml parsing.""" + + def test_simple_paths(self, tmp_path): + (tmp_path / "plugins-list.yaml").write_text( + "plugins/frontend:\nplugins/backend:\n" + ) + result = dm.parse_plugins_list(tmp_path) + assert [p["path"] for p in result] == ["plugins/frontend", "plugins/backend"] + + def test_paths_with_cli_args(self, tmp_path): + (tmp_path / "plugins-list.yaml").write_text( + "plugins/adr-backend: --embed-package @backstage-community/plugin-adr-common\n" + ) + result = dm.parse_plugins_list(tmp_path) + assert result == [{"path": "plugins/adr-backend"}] + + def test_commented_out_lines(self, tmp_path): + (tmp_path / "plugins-list.yaml").write_text( + "#plugins/disabled:\nplugins/enabled:\n" + ) + result = dm.parse_plugins_list(tmp_path) + assert result == [{"path": "plugins/enabled"}] + + def test_indented_continuation_lines_skipped(self, tmp_path): + (tmp_path / "plugins-list.yaml").write_text( + "plugins/main:\n - --some-arg\n - --another\nplugins/other:\n" + ) + result = dm.parse_plugins_list(tmp_path) + assert [p["path"] for p in result] == ["plugins/main", "plugins/other"] + + def test_empty_lines_skipped(self, tmp_path): + (tmp_path / "plugins-list.yaml").write_text( + "plugins/a:\n\nplugins/b:\n" + ) + result = dm.parse_plugins_list(tmp_path) + assert [p["path"] for p in result] == ["plugins/a", "plugins/b"] + + def test_dot_path(self, tmp_path): + (tmp_path / "plugins-list.yaml").write_text(".:\n") + result = dm.parse_plugins_list(tmp_path) + assert result == [{"path": "."}] + + def test_inline_comment_stripped(self, tmp_path): + (tmp_path / "plugins-list.yaml").write_text( + "plugins/foo: # experimental\n" + ) + result = dm.parse_plugins_list(tmp_path) + assert result == [{"path": "plugins/foo"}] + + +class TestShortenName: + """Test Kubernetes name shortening logic.""" + + def test_short_name_unchanged(self): + assert dm.shorten_name("backstage-community-plugin-foo") == "backstage-community-plugin-foo" + + def test_long_name_shortened(self): + long_name = "backstage-community-plugin-very-long-name-that-exceeds-the-kubernetes-limit" + assert len(long_name) > 63 + result = dm.shorten_name(long_name) + assert len(result) <= 63 + assert "bcp" in result + + def test_exactly_63_chars_unchanged(self): + name = "a" * 63 + assert dm.shorten_name(name) == name + + def test_shortening_rules_applied_in_order(self): + name = "backstage-community-plugin-catalog-module-kubernetes-something-extra" + result = dm.shorten_name(name) + assert "bcp" in result + assert "backstage-community-plugin" not in result + + +class TestPackageNameToMetadataName: + """Test npm package name to metadata name derivation.""" + + def test_scoped_package(self): + result = dm.package_name_to_metadata_name("@backstage-community/plugin-argocd") + assert result == "backstage-community-plugin-argocd" + + def test_unscoped_package(self): + result = dm.package_name_to_metadata_name("backstage-plugin-foo") + assert result == "backstage-plugin-foo" + + def test_long_scoped_name_shortened(self): + name = "@backstage-community/plugin-catalog-module-kubernetes-something-extra" + result = dm.package_name_to_metadata_name(name) + assert len(result) <= 63 + + +class TestDeriveTitle: + """Test human-readable title derivation.""" + + def test_basic_plugin(self): + assert dm.derive_title("@backstage-community/plugin-argocd") == "Argocd" + + def test_multi_word(self): + assert dm.derive_title("@backstage-community/plugin-tech-radar") == "Tech Radar" + + def test_with_backstage_prefix(self): + result = dm.derive_title("@scope/backstage-plugin-my-feature") + assert result == "My Feature" + + +class TestDeriveSourceCodeUrl: + """Test source code URL derivation.""" + + def test_non_flat_with_plugin_path(self): + url = dm.derive_source_code_url( + "https://github.com/backstage/community-plugins", + "argocd", "plugins/argocd", flat=False, + ) + assert url == "https://github.com/backstage/community-plugins/tree/main/workspaces/argocd/plugins/argocd" + + def test_non_flat_with_dot_path(self): + url = dm.derive_source_code_url( + "https://github.com/example/repo", + "myws", ".", flat=False, + ) + assert url == "https://github.com/example/repo/tree/main/workspaces/myws" + + def test_flat_with_plugin_path(self): + url = dm.derive_source_code_url( + "https://github.com/example/repo", + "myws", "plugins/foo", flat=True, + ) + assert url == "https://github.com/example/repo/tree/main/plugins/foo" + + def test_flat_with_dot_path(self): + url = dm.derive_source_code_url( + "https://github.com/example/repo", + "myws", ".", flat=True, + ) + assert url == "https://github.com/example/repo/tree/main" + + +class TestDeriveOciUrl: + """Test OCI artifact URL derivation.""" + + def test_basic_oci_url(self): + url = dm.derive_oci_url("backstage-community-plugin-argocd", "1.49.2", "2.8.0") + assert url == ( + "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/" + "backstage-community-plugin-argocd:bs_1.49.2__2.8.0" + "!backstage-community-plugin-argocd" + ) + + +class TestDeriveSupportedVersions: + """Test supportedVersions derivation.""" + + def test_from_source_json(self, tmp_path): + source = {"repo-backstage-version": "1.45.3"} + assert dm.derive_supported_versions(tmp_path, source) == "1.45.3" + + def test_backstage_json_override(self, tmp_path): + (tmp_path / "backstage.json").write_text('{"version": "1.49.2"}') + source = {"repo-backstage-version": "1.45.3"} + assert dm.derive_supported_versions(tmp_path, source) == "1.49.2" + + +class TestFindMissingMetadata: + """Test detection of plugins without metadata files.""" + + def test_all_present(self, tmp_path): + (tmp_path / "metadata").mkdir() + (tmp_path / "metadata" / "some-plugin-argocd.yaml").write_text("kind: Package") + plugins = [{"path": "plugins/argocd"}] + assert dm.find_missing_metadata(tmp_path, plugins) == [] + + def test_missing_metadata(self, tmp_path): + (tmp_path / "metadata").mkdir() + plugins = [{"path": "plugins/argocd"}] + result = dm.find_missing_metadata(tmp_path, plugins) + assert result == [{"path": "plugins/argocd"}] + + def test_no_metadata_dir(self, tmp_path): + plugins = [{"path": "plugins/foo"}] + result = dm.find_missing_metadata(tmp_path, plugins) + assert result == [{"path": "plugins/foo"}] + + def test_dot_path_with_no_metadata(self, tmp_path): + plugins = [{"path": "."}] + result = dm.find_missing_metadata(tmp_path, plugins) + assert result == [{"path": "."}] + + def test_dot_path_with_existing_metadata(self, tmp_path): + (tmp_path / "metadata").mkdir() + (tmp_path / "metadata" / "something.yaml").write_text("kind: Package") + plugins = [{"path": "."}] + result = dm.find_missing_metadata(tmp_path, plugins) + assert result == [] + + +class TestExtractEnvVars: + """Test ${VAR_NAME} extraction from YAML content.""" + + def test_basic_extraction(self): + content = "token: ${MY_TOKEN}\nurl: ${BASE_URL}" + assert dm.extract_env_vars(content) == ["BASE_URL", "MY_TOKEN"] + + def test_no_env_vars(self): + assert dm.extract_env_vars("plain: value") == [] + + def test_deduplication(self): + content = "a: ${TOKEN}\nb: ${TOKEN}" + assert dm.extract_env_vars(content) == ["TOKEN"] + + def test_sorted_output(self): + content = "${ZEBRA}\n${ALPHA}" + assert dm.extract_env_vars(content) == ["ALPHA", "ZEBRA"] + + +class TestDerivePluginFields: + """Test full field derivation for a plugin.""" + + @pytest.fixture + def source(self): + return { + "repo": "https://github.com/backstage/community-plugins", + "repo-ref": "abc123", + "repo-flat": False, + "repo-backstage-version": "1.49.2", + } + + def test_backend_plugin(self, source): + pkg = { + "name": "@backstage-community/plugin-argocd-backend", + "version": "1.4.0", + "backstage": {"role": "backend-plugin"}, + } + result = dm.derive_plugin_fields( + pkg, "argocd", "plugins/argocd-backend", source, "1.49.2", None + ) + assert result["metadata_name"] == "backstage-community-plugin-argocd-backend" + assert result["packageName"] == "@backstage-community/plugin-argocd-backend" + assert result["version"] == "1.4.0" + assert result["role"] == "backend-plugin" + assert result["supportedVersions"] == "1.49.2" + assert "argocd-backend" in result["sourceCodeUrl"] + assert result["support"] == "community" + + def test_copies_existing_metadata(self, source): + existing = {"author": "Red Hat", "lifecycle": "active", "partOf": ["argocd"]} + pkg = { + "name": "@backstage-community/plugin-argocd", + "version": "2.8.0", + "backstage": {"role": "frontend-plugin"}, + } + result = dm.derive_plugin_fields( + pkg, "argocd", "plugins/argocd", source, "1.49.2", existing + ) + assert result["author"] == "Red Hat" + assert result["lifecycle"] == "active" + assert result["partOf"] == ["argocd"] + + def test_support_needs_confirmation_for_ga(self, source): + existing = {"author": "Red Hat", "support": "generally-available"} + pkg = { + "name": "@backstage-community/plugin-foo", + "version": "1.0.0", + "backstage": {"role": "backend-plugin"}, + } + result = dm.derive_plugin_fields( + pkg, "foo", "plugins/foo", source, "1.49.2", existing + ) + assert result["support"] == "generally-available" + assert result["support_needs_confirmation"] is True + + def test_support_needs_confirmation_for_tech_preview(self, source): + existing = {"author": "Red Hat", "support": "tech-preview"} + pkg = { + "name": "@backstage-community/plugin-bar", + "version": "1.0.0", + "backstage": {"role": "backend-plugin"}, + } + result = dm.derive_plugin_fields( + pkg, "bar", "plugins/bar", source, "1.49.2", existing + ) + assert result["support_needs_confirmation"] is True + + def test_community_support_no_confirmation(self, source): + existing = {"author": "Red Hat", "support": "community"} + pkg = { + "name": "@backstage-community/plugin-baz", + "version": "1.0.0", + "backstage": {"role": "backend-plugin"}, + } + result = dm.derive_plugin_fields( + pkg, "baz", "plugins/baz", source, "1.49.2", existing + ) + assert result["support"] == "community" + assert "support_needs_confirmation" not in result + + def test_no_existing_metadata_defaults_to_community(self, source): + pkg = { + "name": "@backstage-community/plugin-new", + "version": "0.1.0", + "backstage": {"role": "frontend-plugin"}, + } + result = dm.derive_plugin_fields( + pkg, "new", "plugins/new", source, "1.49.2", None + ) + assert result["support"] == "community" + assert "support_needs_confirmation" not in result + + def test_flat_repo_source_url(self, source): + source["repo-flat"] = True + pkg = { + "name": "@example/plugin-standalone", + "version": "1.0.0", + "backstage": {"role": "frontend-plugin"}, + } + result = dm.derive_plugin_fields( + pkg, "standalone", ".", source, "1.49.2", None + ) + assert result["sourceCodeUrl"] == "https://github.com/backstage/community-plugins/tree/main" + + +class TestCheckSupportedVersionsConsistency: + """Test audit of supportedVersions across metadata files.""" + + def test_all_consistent(self, tmp_path): + md = tmp_path / "metadata" + md.mkdir() + (md / "plugin-a.yaml").write_text("spec:\n backstage:\n supportedVersions: 1.49.2\n") + (md / "plugin-b.yaml").write_text("spec:\n backstage:\n supportedVersions: 1.49.2\n") + assert dm.check_supported_versions_consistency(tmp_path, "1.49.2") == [] + + def test_mismatch_detected(self, tmp_path): + md = tmp_path / "metadata" + md.mkdir() + (md / "plugin-a.yaml").write_text("spec:\n backstage:\n supportedVersions: 1.45.3\n") + result = dm.check_supported_versions_consistency(tmp_path, "1.49.2") + assert len(result) == 1 + assert result[0]["actual"] == "1.45.3" + assert result[0]["expected"] == "1.49.2" + + +class TestCheckEmptyConfigWithoutFlag: + """Test audit for appConfigExamples: [] without appConfigNotRequired.""" + + def test_no_issues(self, tmp_path): + md = tmp_path / "metadata" + md.mkdir() + (md / "plugin.yaml").write_text( + "spec:\n appConfigNotRequired: true\n appConfigExamples: []\n" + ) + assert dm.check_empty_config_without_flag(tmp_path) == [] + + def test_issue_detected(self, tmp_path): + md = tmp_path / "metadata" + md.mkdir() + (md / "plugin.yaml").write_text("spec:\n appConfigExamples: []\n") + result = dm.check_empty_config_without_flag(tmp_path) + assert result == ["plugin.yaml"] + + def test_non_empty_config_no_issue(self, tmp_path): + md = tmp_path / "metadata" + md.mkdir() + (md / "plugin.yaml").write_text( + "spec:\n appConfigExamples:\n - title: Default\n" + ) + assert dm.check_empty_config_without_flag(tmp_path) == [] + + +class TestReadExistingMetadata: + """Test extraction of copyable fields from existing metadata.""" + + def test_extracts_fields(self, tmp_path): + md = tmp_path / "metadata" + md.mkdir() + (md / "plugin.yaml").write_text(textwrap.dedent("""\ + spec: + author: Red Hat + support: community + lifecycle: active + partOf: + - my-plugin + """)) + result = dm.read_existing_metadata(tmp_path) + assert result["author"] == "Red Hat" + assert result["support"] == "community" + assert result["lifecycle"] == "active" + assert result["partOf"] == ["my-plugin"] + + def test_no_metadata_dir(self, tmp_path): + assert dm.read_existing_metadata(tmp_path) is None + + def test_empty_metadata_dir(self, tmp_path): + (tmp_path / "metadata").mkdir() + assert dm.read_existing_metadata(tmp_path) is None