diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 692e5be0..3367323b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -38,6 +38,7 @@ "vitest.dev", "ms-vscode.powershell", "ms-azuretools.vscode-bicep", + "redhat.vscode-yaml", "bierner.markdown-preview-github-styles" ] } diff --git a/.github/skills/codespace-clone-repo/SKILL.md b/.github/skills/codespace-clone-repo/SKILL.md deleted file mode 100644 index 395a426d..00000000 --- a/.github/skills/codespace-clone-repo/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: "codespace-clone-repo" -description: "Clone another GitHub repository from a Codespace/dev container using GitHub CLI web login (no PAT), especially when GITHUB_TOKEN defaults interfere with auth. Use when gh auth login fails or when testing apiops init/pipeline changes across repos." -domain: "developer-workflow" -confidence: "high" -source: "manual + repeated Codespaces troubleshooting" ---- - -## Context - -Use this skill when a user needs to clone another repository from a Codespace or dev container and must avoid PAT-based auth and environment-provided `GITHUB_TOKEN`/`GH_TOKEN` credentials. - -This pattern is useful for cross-repo validation such as `apiops init` pipeline testing. - -## Fast Path - -Run these commands in order: - -```bash -unset GITHUB_TOKEN GH_TOKEN - -# Optional but recommended to clear stale account state -gh auth logout -h github.com - -# Web/device login, no PAT -env -u GITHUB_TOKEN -u GH_TOKEN gh auth login \ - -h github.com \ - -p https \ - -w \ - --clipboard \ - --insecure-storage - -# Verify auth source is account login, not env token -env -u GITHUB_TOKEN -u GH_TOKEN gh auth status -``` - -Then clone to `/workspaces`: - -```bash -cd /workspaces -gh clone / -``` - -After cloning, open workspaces so all repos can be opened: - -1. In VS Code, go to **File → Open Folder...** -2. Navigate to and select `/workspaces` -3. Click **Open** - -VS Code will reload with the cloned repository as your workspace. - -## If Login Appears Stuck - -If `gh auth login` shows: - -- `First copy your one-time code: XXXX-YYYY` -- `Press Enter to open https://github.com/login/device in your browser...` - -Use a second terminal: - -```bash -$BROWSER https://github.com/login/device -``` - -Enter the one-time code in the browser and authorize, then return to the original terminal and press Enter if needed. - -## Common Failure Modes - -- `gh auth status` shows `(GITHUB_TOKEN)`: - environment token is still active. Re-run with `env -u GITHUB_TOKEN -u GH_TOKEN`. - -- Login exits with code `130`: - usually interrupted (`Ctrl+C`) while waiting for device authorization. Restart login and complete browser step. - -- Browser does not launch from container: - run `$BROWSER https://github.com/login/device` manually from a separate terminal. - -- Clone prompts for credentials unexpectedly: - verify git protocol is HTTPS in `gh auth status` and re-run `gh auth login -p https` if necessary. - -- `git push`/`git fetch` returns `403` even though `gh auth status` looks correct: - Codespaces can inject `GITHUB_TOKEN`/`GH_TOKEN` that override account auth for git operations. - Use token-sanitized commands and force gh credentials for the operation: - - ```bash - env -u GITHUB_TOKEN -u GH_TOKEN git \ - -c credential.helper= \ - -c credential.helper='!gh auth git-credential' \ - push origin - ``` - - For fetch/pull, use the same pattern with `fetch` or `pull`. - -## Safety Notes - -- Never request a PAT for this workflow unless the user explicitly asks for a PAT-based approach. -- Do not route secrets through chat prompts. -- Keep authentication interactive in user terminal when account sign-in is required. - -## Example End-to-End - -```bash -unset GITHUB_TOKEN GH_TOKEN -gh auth logout -h github.com -env -u GITHUB_TOKEN -u GH_TOKEN gh auth login -h github.com -p https -w --clipboard --insecure-storage -env -u GITHUB_TOKEN -u GH_TOKEN gh auth status -cd /workspaces -gh clone / -``` - -If you later need to push from a Codespace, use: - -```bash -cd /workspaces/ -env -u GITHUB_TOKEN -u GH_TOKEN git \ - -c credential.helper= \ - -c credential.helper='!gh auth git-credential' \ - push origin -``` - -Then use **File → Open Folder...** to open `/workspaces` in VS Code. diff --git a/.github/skills/codespace-multi-repo/SKILL.md b/.github/skills/codespace-multi-repo/SKILL.md new file mode 100644 index 00000000..90e3052a --- /dev/null +++ b/.github/skills/codespace-multi-repo/SKILL.md @@ -0,0 +1,199 @@ +--- +name: "codespace-multi-repo" +description: "Clone, pull, and push across multiple repositories from a Codespace/dev container without PATs — GitHub repos via GitHub CLI web login (avoiding GITHUB_TOKEN/GH_TOKEN interference) and Azure DevOps repos via a short-lived az CLI OAuth token. Use when working across more than one repo, gh auth login fails, git push/fetch returns 403/401, or when testing apiops init/pipeline changes across repos." +domain: "developer-workflow" +confidence: "high" +source: "manual + repeated Codespaces troubleshooting" +--- + +## Context + +Use this skill when working with **multiple repositories** from a Codespace or +dev container and you need to **clone, pull, and push** to each without PAT-based +auth or environment-provided `GITHUB_TOKEN`/`GH_TOKEN` credentials. + +Repos may live on different hosts (github.com and Azure DevOps), each with its +own auth path: + +| Host | Clone / pull / push auth | +|------|--------------------------| +| github.com | GitHub CLI account login (`gh auth login -w`), token-sanitized git | +| dev.azure.com | Short-lived OAuth token from `az account get-access-token` | + +This pattern is useful for cross-repo validation such as `apiops init` pipeline +testing, where the CLI repo lives on GitHub and the scaffolded sample repo lives +on Azure DevOps. + +## GitHub Repos: Clone, Pull, Push + +Run these commands in order: + +```bash +unset GITHUB_TOKEN GH_TOKEN + +# Optional but recommended to clear stale account state +gh auth logout -h github.com + +# Web/device login, no PAT +env -u GITHUB_TOKEN -u GH_TOKEN gh auth login \ + -h github.com \ + -p https \ + -w \ + --clipboard \ + --insecure-storage + +# Verify auth source is account login, not env token +env -u GITHUB_TOKEN -u GH_TOKEN gh auth status +``` + +Then clone to `/workspaces`: + +```bash +cd /workspaces +gh clone / +``` + +After cloning, open workspaces so all repos can be opened: + +1. In VS Code, go to **File → Open Folder...** +2. Navigate to and select `/workspaces` +3. Click **Open** + +VS Code will reload with the cloned repository as your workspace. + +Pull and push use the same account auth. If the Codespace-injected env token +overrides it (see Common Failure Modes), force gh credentials per operation: + +```bash +cd /workspaces/ + +# Pull +env -u GITHUB_TOKEN -u GH_TOKEN git \ + -c credential.helper= \ + -c credential.helper='!gh auth git-credential' \ + pull origin + +# Push +env -u GITHUB_TOKEN -u GH_TOKEN git \ + -c credential.helper= \ + -c credential.helper='!gh auth git-credential' \ + push origin +``` + +## If Login Appears Stuck + +If `gh auth login` shows: + +- `First copy your one-time code: XXXX-YYYY` +- `Press Enter to open https://github.com/login/device in your browser...` + +Use a second terminal: + +```bash +$BROWSER https://github.com/login/device +``` + +Enter the one-time code in the browser and authorize, then return to the original terminal and press Enter if needed. + +## Common Failure Modes + +- `gh auth status` shows `(GITHUB_TOKEN)`: + environment token is still active. Re-run with `env -u GITHUB_TOKEN -u GH_TOKEN`. + +- Login exits with code `130`: + usually interrupted (`Ctrl+C`) while waiting for device authorization. Restart login and complete browser step. + +- Browser does not launch from container: + run `$BROWSER https://github.com/login/device` manually from a separate terminal. + +- Clone prompts for credentials unexpectedly: + verify git protocol is HTTPS in `gh auth status` and re-run `gh auth login -p https` if necessary. + +- `git push`/`git fetch` returns `403` even though `gh auth status` looks correct: + Codespaces can inject `GITHUB_TOKEN`/`GH_TOKEN` that override account auth for git operations. + Use token-sanitized commands and force gh credentials for the operation: + + ```bash + env -u GITHUB_TOKEN -u GH_TOKEN git \ + -c credential.helper= \ + -c credential.helper='!gh auth git-credential' \ + push origin + ``` + + For fetch/pull, use the same pattern with `fetch` or `pull`. + +## Azure DevOps Repos: Clone, Pull, Push (no PAT) + +Azure DevOps remotes (`https://dev.azure.com///_git/`) are +not covered by the GitHub credential helper. In a Codespace the only configured +helper is for github.com, so `git push`/`pull`/`clone` hangs or fails auth. +Instead of a PAT, mint a short-lived OAuth token from the Azure CLI and pass it +inline — never echo or persist it. + +```bash +# Ensure the Azure CLI is logged in (interactive if needed — do NOT pass secrets via chat) +az account show --query "{user:user.name, tenant:tenantId}" -o json + +# Well-known, public Azure DevOps resource (application) ID — the same constant +# for every org worldwide; NOT a secret and not specific to your repo/tenant. +# It tells Entra ID to issue a token scoped to Azure DevOps. +ADO_RESOURCE_ID=499b84ac-1321-427f-aa17-267ca6975798 +ADO_TOKEN=$(az account get-access-token \ + --resource "$ADO_RESOURCE_ID" \ + --query accessToken -o tsv) + +# Push using the token as a bearer header; token stays in a variable, never printed +git -c http.extraheader="AUTHORIZATION: Bearer $ADO_TOKEN" push origin +``` + +Clone and pull use the identical header pattern: + +```bash +# Clone +git -c http.extraheader="AUTHORIZATION: Bearer $ADO_TOKEN" \ + clone https://dev.azure.com///_git/ + +# Pull +git -c http.extraheader="AUTHORIZATION: Bearer $ADO_TOKEN" pull origin +``` + +Notes: +- `499b84ac-1321-427f-aa17-267ca6975798` is the fixed, public Azure DevOps + application ID in Microsoft Entra ID — identical for all organizations and not + a credential. Use it as-is for `--resource` (or `.../.default` for scope-based + requests). It is required so Entra ID issues a token Azure DevOps will accept. +- The same `-c http.extraheader=...` pattern works for `fetch`/`pull`/`clone`. +- The az login tenant must be the tenant that backs the Azure DevOps org. If the + push returns `TF400813`/`401`, the logged-in identity has no access to that org + — `az login --tenant ` against the correct tenant first. +- Keep the token in a shell variable (`ADO_TOKEN=$(...)`) and reference it; do not + print it or place it directly in a literal command. + +## Safety Notes + +- Never request a PAT for this workflow unless the user explicitly asks for a PAT-based approach. +- Do not route secrets through chat prompts. +- Keep authentication interactive in user terminal when account sign-in is required. + +## Example End-to-End + +```bash +unset GITHUB_TOKEN GH_TOKEN +gh auth logout -h github.com +env -u GITHUB_TOKEN -u GH_TOKEN gh auth login -h github.com -p https -w --clipboard --insecure-storage +env -u GITHUB_TOKEN -u GH_TOKEN gh auth status +cd /workspaces +gh clone / +``` + +If you later need to push from a Codespace, use: + +```bash +cd /workspaces/ +env -u GITHUB_TOKEN -u GH_TOKEN git \ + -c credential.helper= \ + -c credential.helper='!gh auth git-credential' \ + push origin +``` + +Then use **File → Open Folder...** to open `/workspaces` in VS Code. diff --git a/.github/skills/update-config-schema-version/SKILL.md b/.github/skills/update-config-schema-version/SKILL.md new file mode 100644 index 00000000..ef7bba8b --- /dev/null +++ b/.github/skills/update-config-schema-version/SKILL.md @@ -0,0 +1,129 @@ +--- +name: "update-config-schema-version" +description: "Bump the filter/override JSON Schema version (schemas/v) and update every reference so docs, Copilot prompts, init-generated configs, and tests point at the new version. Use when changing the configuration.extractor.yaml / configuration..yaml schema shape, introducing a breaking schema change, or when 'schemaVersion' in package.json needs to move." +domain: "configuration-schema" +confidence: "high" +source: "manual + observed from schemas/v1 layout and schema-ref.ts/generate-schemas.mjs wiring" +--- + +## Context + +The filter (extractor) and override config JSON Schemas are versioned +**independently of the CLI package version**. Each schema version lives at a +frozen path `schemas/v/` on the `main` branch: + +- **Backward-compatible edits** (add an optional field, loosen validation): + keep the same version and regenerate in place. +- **Breaking changes** (rename/remove a field, tighten validation): introduce a + **new** version folder `schemas/v/` and leave the old folder frozen so + existing config files keep resolving against the shape they were written for. + +The single source of truth is `schemaVersion` in [`package.json`](../../../package.json). + +## What updates automatically (do NOT hand-edit) + +Both of these read `schemaVersion` at build time — bumping `package.json` is +enough for them: + +- [`scripts/generate-schemas.mjs`](../../../scripts/generate-schemas.mjs) — + writes the schema files to `schemas/v/` and stamps the `$id` URL. +- [`src/templates/configs/schema-ref.ts`](../../../src/templates/configs/schema-ref.ts) — + builds the `# yaml-language-server: $schema=` URL injected into the + `apiops init` generated configs (`filter-config.yaml` / `override-config.yaml` + templates use the `{{SCHEMA_URL}}` placeholder). + +Regenerating happens on `prebuild` / `prelint` / `pretest`, or run manually: + +```bash +node scripts/generate-schemas.mjs && node scripts/embed-markdown-templates.mjs +``` + +## What must be updated by hand + +These hardcode `schemas/v/` and will NOT change on their own. Update every +one to the new version. Find them all first: + +```bash +grep -rn "schemas/v[0-9]" \ + src/templates docs tests \ + | grep -v node_modules +``` + +Files that reference the version directly today: + +1. **Copilot prompt templates** (embedded into the package — must be edited at + the source, then re-embed): + - [`src/templates/copilot/configure-filter-prompt.md`](../../../src/templates/copilot/configure-filter-prompt.md) + → `schemas/v/extractor-config.schema.json` + - [`src/templates/copilot/configure-overrides-prompt.md`](../../../src/templates/copilot/configure-overrides-prompt.md) + → `schemas/v/override-config.schema.json` +2. **Docs**: + - [`docs/guides/filtering-resources.md`](../../../docs/guides/filtering-resources.md) + (the `$schema` example line and the "published at `schemas/v/...`" link) + - [`docs/guides/environment-overrides.md`](../../../docs/guides/environment-overrides.md) + (two `$schema` example lines and the "published at `schemas/v/...`" link) +3. **Unit tests** (assert the rendered schema URL): + - [`tests/unit/templates/configs/config-templates.test.ts`](../../../tests/unit/templates/configs/config-templates.test.ts) + (`expect(config).toContain('schemas/v/extractor-config.schema.json')` and + the override equivalent) + +## Procedure + +### 1) Decide compatible vs breaking + +- **Backward-compatible:** keep `schemaVersion` as-is, edit + `src/models/config.ts`, regenerate, done. No reference updates needed. +- **Breaking:** bump `schemaVersion` and continue below. + +### 2) Bump the source of truth + +```jsonc +// package.json +"schemaVersion": "2", +``` + +### 3) Regenerate derived artifacts + +```bash +node scripts/generate-schemas.mjs # creates schemas/v2/*.schema.json +node scripts/embed-markdown-templates.mjs # re-embeds prompt md after step 4 +``` + +`schemas/v2/` is created; **keep `schemas/v1/` in place** (frozen for existing +configs). Do not delete the old version folder. + +### 4) Update every hardcoded reference + +Edit each file listed in "What must be updated by hand" to `v2`, then re-run the +embed script so the prompt changes land in +`src/templates/generated/embedded-markdown.ts`. + +### 5) Verify nothing still points at the old version unintentionally + +```bash +# Should only match the intentionally-frozen old folder and any "previous +# version" historical notes — not docs/prompts/tests describing the current shape. +grep -rn "schemas/v1" src docs tests scripts +``` + +### 6) Build and test + +```bash +npm run build +npx vitest run tests/unit/templates/configs/config-templates.test.ts \ + tests/integration/package-build/package-build.test.ts +``` + +The package-build integration test confirms the embedded templates (including +the updated prompts) ship in the npm pack output. + +## Gotchas + +- The `schemas/v/` folder is intentionally retained on a breaking bump — + removing it breaks configs already pinned to that version. +- Prompt `.md` edits do nothing until `embed-markdown-templates.mjs` re-runs and + the project rebuilds; the embedded constant is what ships, not the raw file. +- `apiops init` output needs no manual change — it derives the URL from + `schemaVersion` via `schema-ref.ts`. +- Keep `package.json schemaVersion` a bare integer string (`"2"`), not `"v2"`; + the `v` prefix is added by the scripts. diff --git a/docs/commands/init.md b/docs/commands/init.md index 3dd14eb1..4879c4eb 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -99,6 +99,8 @@ In interactive mode (the default when running in a terminal), `apiops init` prom | File | Purpose | |------|---------| | `.github/prompts/apiops-setup-identity.prompt.md` | Copilot prompt for identity setup | +| `.github/prompts/apiops-configure-filter.prompt.md` | Copilot prompt for creating extraction filter files | +| `.github/prompts/apiops-configure-overrides.prompt.md` | Copilot prompt for creating environment override files | | `/` | Empty artifact directory (default: `./apim-artifacts`) | ## Package consumption modes @@ -116,11 +118,12 @@ If you pass `--cli-package `, the tarball is copied into a `.apiops/` dire ## Next steps after init -1. **Set up identity** — Follow the generated `IDENTITY-SETUP-*.md` guide to configure Azure credentials for your CI/CD platform. +1. **Set up identity** — Follow the generated `IDENTITY-SETUP-*.md` guide to configure Azure credentials for your CI/CD platform. Or use the `.github/prompts/apiops-setup-identity.prompt.md` Copilot prompt. 2. **Extract your first snapshot** — Run [`apiops extract`](./extract.md) to pull your current APIM configuration into the artifact directory. -3. **Commit and push** — Check the generated files into version control. -4. **Configure overrides** — Edit `configuration.{env}.yaml` files with environment-specific values. See the [environment overrides guide](../guides/environment-overrides.md). -5. **Run your pipeline** — Trigger the publish pipeline to deploy artifacts to your target APIM instance. +3. **Configure filters** — Edit `configuration.extractor.yaml` to control which resources are extracted. Use the `.github/prompts/apiops-configure-filter.prompt.md` Copilot prompt for guided setup. +4. **Commit and push** — Check the generated files into version control. +5. **Configure overrides** — Edit `configuration.{env}.yaml` files with environment-specific values. Use the `.github/prompts/apiops-configure-overrides.prompt.md` Copilot prompt for guided setup. See the [environment overrides guide](../guides/environment-overrides.md). +6. **Run your pipeline** — Trigger the publish pipeline to deploy artifacts to your target APIM instance. ## Related docs diff --git a/docs/guides/environment-overrides.md b/docs/guides/environment-overrides.md index 7b6b94f4..0f355488 100644 --- a/docs/guides/environment-overrides.md +++ b/docs/guides/environment-overrides.md @@ -23,6 +23,23 @@ apiops publish \ --overrides ./configuration.prod.yaml ``` +## Copilot-Assisted Configuration + +If you ran `apiops init`, a Copilot prompt file was generated at `.github/prompts/apiops-configure-overrides.prompt.md`. Open it in VS Code and ask GitHub Copilot to help you configure environment overrides — it will guide you through setting up environment-specific values interactively. + +## IDE Autocompletion with JSON Schema + +A JSON Schema is available for `configuration.{env}.yaml` override files. Add yaml-language-server comment at the top of your override file. Requires yaml language extension in VSCode. + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas/v1/override-config.schema.json +``` + +The schema provides: +- Property name autocompletion for all resource sections +- Validation of the override structure (name + properties format) +- Inline documentation including token substitution syntax + ## Override file format (APIOps Toolkit-compatible) `apiops-cli` uses the [APIOps Toolkit](https://github.com/Azure/apiops) override layout: @@ -556,3 +573,4 @@ apiops publish --overrides configuration.prod.yaml --dry-run \ - [Authentication Guide](authentication.md) - [Scenarios and Workflows](scenarios-and-workflows.md) - [GitHub Actions Integration](../ci-cd/github-actions.md) + diff --git a/docs/guides/filtering-resources.md b/docs/guides/filtering-resources.md index 93e655a4..8d0b77e8 100644 --- a/docs/guides/filtering-resources.md +++ b/docs/guides/filtering-resources.md @@ -36,6 +36,18 @@ Only `petstore-api`, `orders-api`, and their transitive dependencies are extract --- +## IDE Autocomplete (JSON Schema) + +Add yaml-language-server comment at the top of your override file. Requires yaml language extension in VSCode. + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas/v1/extractor-config.schema.json +``` + +The schema validates field names, array structure, and sub-resource filters. It is published at [`schemas/v1/extractor-config.schema.json`](https://github.com/Azure/apiops-cli/blob/main/schemas/v1/extractor-config.schema.json). + +--- + ## Filter YAML Format The filter file is a YAML document where each key is a resource type and the value is an array of resource names: @@ -336,3 +348,9 @@ backends: - [Environment Overrides](environment-overrides.md) — per-environment configuration - [Configuration Reference](../reference/configuration.md) — config priority chain - [APIM Glossary](../reference/apim-glossary.md) — APIM resource terminology + +--- + +## Copilot-Assisted Configuration + +If you ran `apiops init`, a Copilot prompt file was generated at `.github/prompts/apiops-configure-filter.prompt.md`. Open it in VS Code and ask GitHub Copilot to help you configure your filter — it will walk you through selecting resources interactively. diff --git a/package.json b/package.json index 73bc7b43..7e084e63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@peterhauge/apiops-cli", "version": "0.3.0-alpha.0", + "schemaVersion": "1", "description": "CLI tool for Azure API Management configuration-as-code", "type": "module", "private": false, @@ -15,9 +16,9 @@ ], "readme": "README.md", "scripts": { - "prelint": "node scripts/embed-markdown-templates.mjs", - "prebuild": "node scripts/embed-markdown-templates.mjs", - "pretest": "node scripts/embed-markdown-templates.mjs", + "prelint": "node scripts/embed-markdown-templates.mjs && node scripts/generate-schemas.mjs", + "prebuild": "node scripts/embed-markdown-templates.mjs && node scripts/generate-schemas.mjs", + "pretest": "node scripts/embed-markdown-templates.mjs && node scripts/generate-schemas.mjs", "build": "tsc", "test": "vitest run", "test:watch": "vitest", diff --git a/schemas/v1/extractor-config.schema.json b/schemas/v1/extractor-config.schema.json new file mode 100644 index 00000000..1ea17a9b --- /dev/null +++ b/schemas/v1/extractor-config.schema.json @@ -0,0 +1,221 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$comment": "Copyright (c) Microsoft Corporation. Licensed under the MIT license. Schema version: v1.", + "$id": "https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas/v1/extractor-config.schema.json", + "title": "APIOps Filter Configuration", + "description": "Validates configuration.extractor.yaml files used by APIOps CLI to select which Azure API Management resources are extracted. All resource sections are optional.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Optional schema URI for editor and IDE validation." + }, + "apis": { + "type": "array", + "description": "APIs to extract. Each item can be either a plain API name or wildcard pattern, or an object with a single API name mapped to nested API sub-filters. Matching is case-insensitive and supports * and ? wildcards.", + "items": { + "$ref": "#/definitions/apiSelector" + } + }, + "backends": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Backends to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "products": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Products to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "namedValues": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Named values to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "loggers": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Loggers to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "diagnostics": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Diagnostics to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "tags": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Tags to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "policyFragments": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Policy fragments to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "gateways": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Gateways to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "versionSets": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Version sets to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "groups": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Groups to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "subscriptions": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Subscriptions to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "schemas": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Schemas to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "policies": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Service-level policies to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "policyRestrictions": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Policy restrictions to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "documentations": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Documentations to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "workspaces": { + "type": "array", + "description": "Workspaces to extract. Each item can be either a plain workspace name or wildcard pattern, or an object with a single workspace name mapped to nested workspace sub-filters. Matching is case-insensitive and supports * and ? wildcards.", + "items": { + "$ref": "#/definitions/workspaceSelector" + } + } + }, + "definitions": { + "resourcePattern": { + "type": "string", + "description": "A resource name or wildcard pattern. Matching is case-insensitive. Supported wildcards: * matches zero or more characters, and ? matches a single character." + }, + "resourcePatternArray": { + "type": "array", + "description": "A list of resource names or wildcard patterns. Matching is case-insensitive. Supported wildcards: * and ?.", + "items": { + "$ref": "#/definitions/resourcePattern" + } + }, + "apiSelector": { + "oneOf": [ + { + "$ref": "#/definitions/resourcePattern" + }, + { + "type": "object", + "description": "A single API name mapped to sub-resource filters for that API.", + "minProperties": 1, + "maxProperties": 1, + "patternProperties": { + "^.+$": { + "$ref": "#/definitions/apiSubFilter" + } + }, + "additionalProperties": false + } + ] + }, + "workspaceSelector": { + "oneOf": [ + { + "$ref": "#/definitions/resourcePattern" + }, + { + "type": "object", + "description": "A single workspace name mapped to sub-resource filters for that workspace.", + "minProperties": 1, + "maxProperties": 1, + "patternProperties": { + "^.+$": { + "$ref": "#/definitions/workspaceSubFilter" + } + }, + "additionalProperties": false + } + ] + }, + "apiSubFilter": { + "type": "object", + "description": "Sub-resource filters for a specific API. Omit a property to include all sub-resources of that type, or set it to an empty array to exclude all of that type. Matching is case-insensitive and supports * and ? wildcards.", + "additionalProperties": false, + "properties": { + "operations": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Operations to extract for this API. Matching is case-insensitive and supports * and ? wildcards." + }, + "diagnostics": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Diagnostics to extract for this API. Matching is case-insensitive and supports * and ? wildcards." + }, + "schemas": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Schemas to extract for this API. Matching is case-insensitive and supports * and ? wildcards." + }, + "releases": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Releases to extract for this API. Matching is case-insensitive and supports * and ? wildcards." + } + } + }, + "workspaceSubFilter": { + "type": "object", + "description": "Sub-resource filters for a specific workspace. Omit a property to include all resources of that type, or set it to an empty array to exclude all of that type. Matching is case-insensitive and supports * and ? wildcards.", + "additionalProperties": false, + "properties": { + "apis": { + "type": "array", + "description": "APIs within this workspace to extract. Each item can be either a plain API name or wildcard pattern, or an object with a single API name mapped to nested API sub-filters. Matching is case-insensitive and supports * and ? wildcards.", + "items": { + "$ref": "#/definitions/apiSelector" + } + }, + "backends": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Backends within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "diagnostics": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Diagnostics within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "groups": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Groups within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "loggers": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Loggers within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "namedValues": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Named values within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "policyFragments": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Policy fragments within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "products": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Products within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "schemas": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Schemas within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "subscriptions": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Subscriptions within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "tags": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Tags within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + }, + "versionSets": { + "$ref": "#/definitions/resourcePatternArray", + "description": "Version sets within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards." + } + } + } + } +} diff --git a/schemas/v1/override-config.schema.json b/schemas/v1/override-config.schema.json new file mode 100644 index 00000000..51197471 --- /dev/null +++ b/schemas/v1/override-config.schema.json @@ -0,0 +1,707 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$comment": "Copyright (c) Microsoft Corporation. Licensed under the MIT license. Schema version: v1.", + "$id": "https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas/v1/override-config.schema.json", + "title": "APIOps Override Configuration", + "description": "Validates configuration.{env}.yaml override files used by APIOps CLI to apply environment-specific property overrides during publish. All resource sections are optional.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Optional schema URI for editor and IDE validation." + }, + "namedValues": { + "$ref": "#/definitions/namedValueOverrideSection", + "description": "Named value overrides. Use the properties object to deep-merge resource properties. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "backends": { + "$ref": "#/definitions/backendOverrideSection", + "description": "Backend overrides. Use the properties object to deep-merge resource properties such as URLs or credentials. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "apis": { + "$ref": "#/definitions/apiOverrideSection", + "description": "API overrides. Each entry can override API properties and optionally define nested diagnostics, operations, policies, and releases. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "diagnostics": { + "$ref": "#/definitions/diagnosticOverrideSection", + "description": "Diagnostics overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "loggers": { + "$ref": "#/definitions/loggerOverrideSection", + "description": "Loggers overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "policies": { + "$ref": "#/definitions/policyOverrideSection", + "description": "Service-level policies overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "gateways": { + "$ref": "#/definitions/overrideSection", + "description": "Gateways overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "versionSets": { + "$ref": "#/definitions/overrideSection", + "description": "Version sets overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "groups": { + "$ref": "#/definitions/overrideSection", + "description": "Groups overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "subscriptions": { + "$ref": "#/definitions/overrideSection", + "description": "Subscriptions overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "products": { + "$ref": "#/definitions/overrideSection", + "description": "Products overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "tags": { + "$ref": "#/definitions/overrideSection", + "description": "Tags overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "policyFragments": { + "$ref": "#/definitions/overrideSection", + "description": "Policy fragments overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "workspaces": { + "$ref": "#/definitions/overrideSection", + "description": "Workspaces overrides. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + } + }, + "definitions": { + "propertiesObject": { + "type": "object", + "description": "Properties to deep-merge into the target resource. Any property name is allowed. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution.", + "additionalProperties": true + }, + "overrideEntry": { + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Resource name to match for this override entry." + }, + "properties": { + "$ref": "#/definitions/propertiesObject" + } + } + }, + "overrideSection": { + "type": "array", + "description": "A list of override entries for a resource type.", + "items": { + "$ref": "#/definitions/overrideEntry" + } + }, + "namedValueOverrideEntry": { + "type": "object", + "required": [ + "name", + "properties" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Named value name to match for this override entry." + }, + "properties": { + "$ref": "#/definitions/namedValuePropertiesObject" + } + } + }, + "namedValueOverrideSection": { + "type": "array", + "description": "A list of named value override entries.", + "items": { + "$ref": "#/definitions/namedValueOverrideEntry" + } + }, + "backendOverrideEntry": { + "type": "object", + "required": [ + "name", + "properties" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Backend name to match for this override entry." + }, + "properties": { + "$ref": "#/definitions/backendPropertiesObject" + } + } + }, + "backendOverrideSection": { + "type": "array", + "description": "A list of backend override entries.", + "items": { + "$ref": "#/definitions/backendOverrideEntry" + } + }, + "loggerOverrideEntry": { + "type": "object", + "required": [ + "name", + "properties" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Logger name to match for this override entry." + }, + "properties": { + "$ref": "#/definitions/loggerPropertiesObject" + } + } + }, + "loggerOverrideSection": { + "type": "array", + "description": "A list of logger override entries.", + "items": { + "$ref": "#/definitions/loggerOverrideEntry" + } + }, + "diagnosticOverrideEntry": { + "type": "object", + "required": [ + "name", + "properties" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Diagnostic name to match for this override entry." + }, + "properties": { + "$ref": "#/definitions/diagnosticPropertiesObject" + } + } + }, + "diagnosticOverrideSection": { + "type": "array", + "description": "A list of diagnostic override entries.", + "items": { + "$ref": "#/definitions/diagnosticOverrideEntry" + } + }, + "policyOverrideEntry": { + "type": "object", + "required": [ + "name", + "properties" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Policy name to match for this override entry." + }, + "properties": { + "$ref": "#/definitions/policyPropertiesObject" + } + } + }, + "policyOverrideSection": { + "type": "array", + "description": "A list of policy override entries.", + "items": { + "$ref": "#/definitions/policyOverrideEntry" + } + }, + "operationOverrideEntry": { + "type": "object", + "required": [ + "name", + "properties" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Operation name to match for this override entry." + }, + "properties": { + "$ref": "#/definitions/propertiesObject" + }, + "policies": { + "$ref": "#/definitions/policyOverrideSection", + "description": "Policy overrides nested under this operation. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + } + } + }, + "operationOverrideSection": { + "type": "array", + "description": "A list of operation override entries.", + "items": { + "$ref": "#/definitions/operationOverrideEntry" + } + }, + "apiOverrideEntry": { + "type": "object", + "required": [ + "name", + "properties" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "API name to match for this override entry." + }, + "properties": { + "$ref": "#/definitions/apiPropertiesObject" + }, + "diagnostics": { + "$ref": "#/definitions/diagnosticOverrideSection", + "description": "Diagnostic overrides nested under this API. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "operations": { + "$ref": "#/definitions/operationOverrideSection", + "description": "Operation overrides nested under this API. Each operation can define its own nested policies. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "policies": { + "$ref": "#/definitions/policyOverrideSection", + "description": "Policy overrides nested directly under this API. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + }, + "releases": { + "$ref": "#/definitions/overrideSection", + "description": "Release overrides nested under this API. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution." + } + } + }, + "apiOverrideSection": { + "type": "array", + "description": "A list of API override entries.", + "items": { + "$ref": "#/definitions/apiOverrideEntry" + } + }, + "apiPropertiesObject": { + "type": "object", + "description": "Common API properties with editor autocomplete. Additional API properties are allowed.", + "properties": { + "displayName": { + "type": "string", + "description": "Friendly API display name in the APIM portal." + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional API description." + }, + "path": { + "type": "string", + "description": "API URL suffix/path." + }, + "serviceUrl": { + "type": [ + "string", + "null" + ], + "description": "Backend service URL for this API." + }, + "apiType": { + "type": "string", + "description": "API kind used by APIM import/export logic.", + "enum": [ + "http", + "soap", + "graphql", + "websocket", + "odata", + "grpc", + "mcp", + "a2a" + ] + }, + "type": { + "type": "string", + "description": "Source API type from extracted API metadata. Use the same values as apiType.", + "enum": [ + "http", + "soap", + "graphql", + "websocket", + "odata", + "grpc", + "mcp", + "a2a" + ] + }, + "protocols": { + "type": "array", + "description": "Supported frontend protocols for this API.", + "items": { + "type": "string", + "enum": [ + "http", + "https", + "ws", + "wss" + ] + }, + "uniqueItems": true + }, + "subscriptionRequired": { + "type": "boolean", + "description": "Whether a subscription key is required to call this API." + }, + "subscriptionKeyParameterNames": { + "type": "object", + "description": "Custom subscription key header/query names.", + "properties": { + "header": { + "type": "string" + }, + "query": { + "type": "string" + } + }, + "additionalProperties": false + }, + "apiRevision": { + "type": "string", + "description": "API revision identifier." + }, + "apiRevisionDescription": { + "type": [ + "string", + "null" + ], + "description": "Description for the API revision." + }, + "apiVersion": { + "type": "string", + "description": "API version label." + }, + "isCurrent": { + "type": "boolean", + "description": "Marks this API revision as current." + }, + "apiVersionSetId": { + "type": "string", + "description": "Reference to the API version set resource." + }, + "format": { + "type": "string", + "description": "Specification format used for API import/export payloads.", + "enum": [ + "openapi", + "openapi+json", + "openapi-link", + "swagger-json", + "swagger-link", + "wsdl", + "wsdl-link", + "wadl-xml", + "wadl-link", + "graphql-link" + ] + }, + "value": { + "type": "string", + "description": "Inline specification content or URL depending on format." + }, + "authenticationSettings": { + "type": "object", + "description": "Authentication settings for backend authorization.", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "namedValuePropertiesObject": { + "type": "object", + "description": "Common named value properties with editor autocomplete. Additional named value properties are allowed.", + "properties": { + "displayName": { + "type": "string", + "description": "Friendly named value display name in the APIM portal." + }, + "value": { + "type": [ + "string", + "null" + ], + "description": "Literal named value content (avoid putting secrets directly in source control)." + }, + "secret": { + "type": "boolean", + "description": "Whether the named value is treated as a secret." + }, + "tags": { + "type": "array", + "description": "Named value tags.", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "keyVault": { + "type": "object", + "description": "Key Vault secret reference for this named value.", + "properties": { + "secretIdentifier": { + "type": "string", + "description": "Full Key Vault secret identifier URL." + }, + "identityClientId": { + "type": [ + "string", + "null" + ], + "description": "User-assigned managed identity client ID used to access the secret." + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "backendPropertiesObject": { + "type": "object", + "description": "Common backend properties with editor autocomplete. Additional backend properties are allowed.", + "properties": { + "title": { + "type": [ + "string", + "null" + ], + "description": "Optional backend title." + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional backend description." + }, + "url": { + "type": [ + "string", + "null" + ], + "description": "Backend runtime URL." + }, + "protocol": { + "type": "string", + "description": "Backend protocol.", + "enum": [ + "http", + "soap" + ] + }, + "resourceId": { + "type": [ + "string", + "null" + ], + "description": "Linked Azure resource ID, when applicable." + }, + "credentials": { + "type": "object", + "description": "Backend authentication credentials object.", + "additionalProperties": true + }, + "proxy": { + "type": "object", + "description": "Proxy settings for backend connectivity.", + "additionalProperties": true + }, + "tls": { + "type": "object", + "description": "TLS settings for backend connectivity.", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "loggerPropertiesObject": { + "type": "object", + "description": "Common logger properties with editor autocomplete. Additional logger properties are allowed.", + "properties": { + "loggerType": { + "type": "string", + "description": "Logger type.", + "enum": [ + "applicationInsights", + "azureEventHub" + ] + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Optional logger description." + }, + "resourceId": { + "type": [ + "string", + "null" + ], + "description": "Linked Azure resource ID for the logger target." + }, + "isBuffered": { + "type": "boolean", + "description": "Whether messages are buffered before being sent." + }, + "credentials": { + "type": "object", + "description": "Logger credentials (for example instrumentationKey or connection string).", + "properties": { + "instrumentationKey": { + "type": "string", + "description": "Application Insights instrumentation key or named value reference." + }, + "name": { + "type": "string", + "description": "Event Hub name, where applicable." + }, + "connectionString": { + "type": "string", + "description": "Event Hub connection string or named value reference." + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "diagnosticPropertiesObject": { + "type": "object", + "description": "Common diagnostic properties with editor autocomplete. Additional diagnostic properties are allowed.", + "properties": { + "alwaysLog": { + "type": [ + "string", + "null" + ], + "description": "Diagnostic always-log behavior.", + "enum": [ + "allErrors", + "always", + null + ] + }, + "httpCorrelationProtocol": { + "type": [ + "string", + "null" + ], + "description": "HTTP correlation protocol for tracing.", + "enum": [ + "Legacy", + "W3C", + "None", + null + ] + }, + "logClientIp": { + "type": "boolean", + "description": "Whether client IP address is logged." + }, + "verbosity": { + "type": "string", + "description": "Diagnostic verbosity level.", + "enum": [ + "verbose", + "information", + "error", + "Verbose", + "Information", + "Error" + ] + }, + "loggerId": { + "type": [ + "string", + "null" + ], + "description": "Target logger ARM resource ID." + }, + "sampling": { + "type": "object", + "description": "Diagnostic sampling configuration.", + "properties": { + "samplingType": { + "type": "string", + "enum": [ + "fixed" + ] + }, + "percentage": { + "type": "number" + } + }, + "additionalProperties": true + }, + "frontend": { + "type": [ + "object", + "null" + ], + "description": "Frontend request/response diagnostic settings.", + "additionalProperties": true + }, + "backend": { + "type": [ + "object", + "null" + ], + "description": "Backend request/response diagnostic settings.", + "additionalProperties": true + }, + "largeLanguageModel": { + "type": [ + "object", + "null" + ], + "description": "LLM diagnostic settings.", + "additionalProperties": true + }, + "tags": { + "type": [ + "object", + "null" + ], + "description": "Diagnostic tags.", + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "policyPropertiesObject": { + "type": "object", + "description": "Common policy properties with editor autocomplete. Additional policy properties are allowed.", + "properties": { + "format": { + "type": "string", + "description": "Policy content format.", + "enum": [ + "rawxml", + "rawxml-link", + "xml", + "xml-link" + ] + }, + "value": { + "type": "string", + "description": "Inline policy XML or linked value, depending on format." + } + }, + "additionalProperties": true + } + } +} diff --git a/scripts/embed-markdown-templates.mjs b/scripts/embed-markdown-templates.mjs index 75502c89..9f8af03f 100644 --- a/scripts/embed-markdown-templates.mjs +++ b/scripts/embed-markdown-templates.mjs @@ -13,6 +13,14 @@ const templates = [ exportName: 'copilotAzureDevOpsIdentitySetupPromptTemplate', path: 'src/templates/copilot/identity-setup-prompt-azure-devops.md', }, + { + exportName: 'copilotConfigureFilterPromptTemplate', + path: 'src/templates/copilot/configure-filter-prompt.md', + }, + { + exportName: 'copilotConfigureOverridesPromptTemplate', + path: 'src/templates/copilot/configure-overrides-prompt.md', + }, { exportName: 'azureDevOpsIdentitySetupCoreTemplate', path: 'src/templates/shared/identity-setup-azure-devops-core.md', @@ -33,6 +41,14 @@ const templates = [ exportName: 'copilotGithubEnvironmentSecretCommandsTemplate', path: 'src/templates/shared/github-environment-secret-commands.md', }, + { + exportName: 'filterConfigTemplate', + path: 'src/templates/configs/filter-config.yaml', + }, + { + exportName: 'overrideConfigTemplate', + path: 'src/templates/configs/override-config.yaml', + }, ]; const rendered = await Promise.all( diff --git a/scripts/generate-schemas.mjs b/scripts/generate-schemas.mjs new file mode 100644 index 00000000..66760298 --- /dev/null +++ b/scripts/generate-schemas.mjs @@ -0,0 +1,662 @@ +/** + * Generates JSON Schema files for the filter (extractor) and override configurations. + * Derives schemas from the canonical TypeScript interfaces in src/models/config.ts. + * + * Run: node scripts/generate-schemas.mjs + * Hooked into: prebuild, prelint, pretest (alongside embed-markdown-templates) + * + * The script reads config.ts to extract the field names from FilterConfig and + * OverrideConfig interfaces, then generates the full JSON Schema files into + * schemas/. This ensures the schemas stay in sync with the TypeScript types + * without requiring a heavy ts-json-schema-generator dependency. + */ + +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const repoRoot = resolve(import.meta.dirname, '..'); +const configPath = resolve(repoRoot, 'src/models/config.ts'); + +// Schemas are versioned independently of the CLI package version. Each schema +// version lives at a frozen path (schemas/v/...) on the `main` branch: +// backward-compatible edits update the current version in place, while a +// breaking change introduces a new version folder. The `main` ref always +// resolves, and the versioned path keeps existing configs pointing at the +// schema shape they were written against. +const pkg = JSON.parse(await readFile(resolve(repoRoot, 'package.json'), 'utf8')); +const schemaVersion = pkg.schemaVersion ?? '1'; +const schemaDirName = `v${schemaVersion}`; +const schemasDir = resolve(repoRoot, 'schemas', schemaDirName); +const SCHEMA_BASE = 'https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas'; +const schemaId = (fileName) => `${SCHEMA_BASE}/${schemaDirName}/${fileName}`; + +// Read the config.ts source to extract interface fields +const configSource = await readFile(configPath, 'utf8'); + +/** + * Extract field names from a TypeScript interface definition. + * Matches lines like: `fieldName?: Type;` + */ +function extractFields(interfaceName, source) { + const regex = new RegExp( + `interface\\s+${interfaceName}\\s*\\{([^}]+)\\}`, + 's' + ); + const match = source.match(regex); + if (!match) { + throw new Error(`Could not find interface ${interfaceName} in config.ts`); + } + const body = match[1]; + const fieldRegex = /^\s*(?:\/\*\*[^*]*\*\/\s*)?(\w+)\??\s*:/gm; + const fields = []; + let m; + while ((m = fieldRegex.exec(body)) !== null) { + fields.push(m[1]); + } + return fields; +} + +// --- Extractor (Filter) Config Schema --- + +const filterFields = extractFields('FilterConfig', configSource); +const apiSubFilterFields = extractFields('ApiSubFilter', configSource); +const workspaceSubFilterFields = extractFields('WorkspaceSubFilter', configSource); + +// Fields that use special handling (not simple resourcePatternArray) +const SPECIAL_FILTER_FIELDS = new Set([ + 'apis', 'workspaces', 'apiSubFilters', 'workspaceSubFilters', +]); + +// Human-friendly labels for resource types +const RESOURCE_LABELS = { + apis: 'APIs', + backends: 'Backends', + products: 'Products', + namedValues: 'Named values', + loggers: 'Loggers', + diagnostics: 'Diagnostics', + tags: 'Tags', + policyFragments: 'Policy fragments', + gateways: 'Gateways', + versionSets: 'Version sets', + groups: 'Groups', + subscriptions: 'Subscriptions', + schemas: 'Schemas', + policies: 'Service-level policies', + policyRestrictions: 'Policy restrictions', + documentations: 'Documentations', + workspaces: 'Workspaces', +}; + +function buildFilterProperties() { + const props = { + $schema: { + type: 'string', + description: 'Optional schema URI for editor and IDE validation.', + }, + }; + + for (const field of filterFields) { + // Skip internal-only fields that don't appear in YAML + if (field === 'apiSubFilters' || field === 'workspaceSubFilters') continue; + + const label = RESOURCE_LABELS[field] || field; + + if (field === 'apis') { + props.apis = { + type: 'array', + description: `${label} to extract. Each item can be either a plain API name or wildcard pattern, or an object with a single API name mapped to nested API sub-filters. Matching is case-insensitive and supports * and ? wildcards.`, + items: { $ref: '#/definitions/apiSelector' }, + }; + } else if (field === 'workspaces') { + props.workspaces = { + type: 'array', + description: `${label} to extract. Each item can be either a plain workspace name or wildcard pattern, or an object with a single workspace name mapped to nested workspace sub-filters. Matching is case-insensitive and supports * and ? wildcards.`, + items: { $ref: '#/definitions/workspaceSelector' }, + }; + } else { + props[field] = { + $ref: '#/definitions/resourcePatternArray', + description: `${label} to extract. Matching is case-insensitive and supports * and ? wildcards.`, + }; + } + } + + return props; +} + +function buildApiSubFilterProperties() { + const props = {}; + for (const field of apiSubFilterFields) { + const label = field.charAt(0).toUpperCase() + field.slice(1); + props[field] = { + $ref: '#/definitions/resourcePatternArray', + description: `${label} to extract for this API. Matching is case-insensitive and supports * and ? wildcards.`, + }; + } + return props; +} + +function buildWorkspaceSubFilterProperties() { + const props = {}; + for (const field of workspaceSubFilterFields) { + // Skip internal-only fields + if (field === 'apiSubFilters') continue; + + const label = RESOURCE_LABELS[field] || field; + + if (field === 'apis') { + props.apis = { + type: 'array', + description: `${label} within this workspace to extract. Each item can be either a plain API name or wildcard pattern, or an object with a single API name mapped to nested API sub-filters. Matching is case-insensitive and supports * and ? wildcards.`, + items: { $ref: '#/definitions/apiSelector' }, + }; + } else { + props[field] = { + $ref: '#/definitions/resourcePatternArray', + description: `${label} within this workspace to extract. Matching is case-insensitive and supports * and ? wildcards.`, + }; + } + } + return props; +} + +const LICENSE_COMMENT = 'Copyright (c) Microsoft Corporation. Licensed under the MIT license.'; +const versionComment = `${LICENSE_COMMENT} Schema version: ${schemaDirName}.`; + +const extractorSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + $comment: versionComment, + $id: schemaId('extractor-config.schema.json'), + title: 'APIOps Filter Configuration', + description: + 'Validates configuration.extractor.yaml files used by APIOps CLI to select which Azure API Management resources are extracted. All resource sections are optional.', + type: 'object', + additionalProperties: false, + properties: buildFilterProperties(), + definitions: { + resourcePattern: { + type: 'string', + description: + 'A resource name or wildcard pattern. Matching is case-insensitive. Supported wildcards: * matches zero or more characters, and ? matches a single character.', + }, + resourcePatternArray: { + type: 'array', + description: + 'A list of resource names or wildcard patterns. Matching is case-insensitive. Supported wildcards: * and ?.', + items: { $ref: '#/definitions/resourcePattern' }, + }, + apiSelector: { + oneOf: [ + { $ref: '#/definitions/resourcePattern' }, + { + type: 'object', + description: + 'A single API name mapped to sub-resource filters for that API.', + minProperties: 1, + maxProperties: 1, + patternProperties: { + '^.+$': { $ref: '#/definitions/apiSubFilter' }, + }, + additionalProperties: false, + }, + ], + }, + workspaceSelector: { + oneOf: [ + { $ref: '#/definitions/resourcePattern' }, + { + type: 'object', + description: + 'A single workspace name mapped to sub-resource filters for that workspace.', + minProperties: 1, + maxProperties: 1, + patternProperties: { + '^.+$': { $ref: '#/definitions/workspaceSubFilter' }, + }, + additionalProperties: false, + }, + ], + }, + apiSubFilter: { + type: 'object', + description: + 'Sub-resource filters for a specific API. Omit a property to include all sub-resources of that type, or set it to an empty array to exclude all of that type. Matching is case-insensitive and supports * and ? wildcards.', + additionalProperties: false, + properties: buildApiSubFilterProperties(), + }, + workspaceSubFilter: { + type: 'object', + description: + 'Sub-resource filters for a specific workspace. Omit a property to include all resources of that type, or set it to an empty array to exclude all of that type. Matching is case-insensitive and supports * and ? wildcards.', + additionalProperties: false, + properties: buildWorkspaceSubFilterProperties(), + }, + }, +}; + +// --- Override Config Schema --- + +const overrideFields = extractFields('OverrideConfig', configSource); + +function buildOverrideProperties() { + const props = { + $schema: { + type: 'string', + description: 'Optional schema URI for editor and IDE validation.', + }, + }; + + for (const field of overrideFields) { + const label = RESOURCE_LABELS[field] || field.charAt(0).toUpperCase() + field.slice(1); + const tokenNote = 'Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution.'; + + if (field === 'apis') { + props.apis = { + $ref: '#/definitions/apiOverrideSection', + description: `API overrides. Each entry can override API properties and optionally define nested diagnostics, operations, policies, and releases. ${tokenNote}`, + }; + } else if (field === 'namedValues') { + props.namedValues = { + $ref: '#/definitions/namedValueOverrideSection', + description: `Named value overrides. Use the properties object to deep-merge resource properties. ${tokenNote}`, + }; + } else if (field === 'backends') { + props.backends = { + $ref: '#/definitions/backendOverrideSection', + description: `Backend overrides. Use the properties object to deep-merge resource properties such as URLs or credentials. ${tokenNote}`, + }; + } else if (field === 'loggers') { + props.loggers = { + $ref: '#/definitions/loggerOverrideSection', + description: `Loggers overrides. ${tokenNote}`, + }; + } else if (field === 'diagnostics') { + props.diagnostics = { + $ref: '#/definitions/diagnosticOverrideSection', + description: `Diagnostics overrides. ${tokenNote}`, + }; + } else if (field === 'policies') { + props.policies = { + $ref: '#/definitions/policyOverrideSection', + description: `Service-level policies overrides. ${tokenNote}`, + }; + } else { + props[field] = { + $ref: '#/definitions/overrideSection', + description: `${label} overrides. ${tokenNote}`, + }; + } + } + + return props; +} + +const overrideSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + $comment: versionComment, + $id: schemaId('override-config.schema.json'), + title: 'APIOps Override Configuration', + description: + 'Validates configuration.{env}.yaml override files used by APIOps CLI to apply environment-specific property overrides during publish. All resource sections are optional.', + type: 'object', + additionalProperties: false, + properties: buildOverrideProperties(), + definitions: { + propertiesObject: { + type: 'object', + description: + 'Properties to deep-merge into the target resource. Any property name is allowed. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution.', + additionalProperties: true, + }, + overrideEntry: { + type: 'object', + // Only 'name' is required: the loader treats 'properties' as optional and + // falls back to inline fields when it is omitted (see src/lib/config-loader.ts). + required: ['name'], + additionalProperties: false, + properties: { + name: { + type: 'string', + description: 'Resource name to match for this override entry.', + }, + properties: { $ref: '#/definitions/propertiesObject' }, + }, + }, + overrideSection: { + type: 'array', + description: 'A list of override entries for a resource type.', + items: { $ref: '#/definitions/overrideEntry' }, + }, + + // --- Named value typed section --- + namedValueOverrideEntry: { + type: 'object', + required: ['name', 'properties'], + additionalProperties: false, + properties: { + name: { type: 'string', description: 'Named value name to match for this override entry.' }, + properties: { $ref: '#/definitions/namedValuePropertiesObject' }, + }, + }, + namedValueOverrideSection: { + type: 'array', + description: 'A list of named value override entries.', + items: { $ref: '#/definitions/namedValueOverrideEntry' }, + }, + + // --- Backend typed section --- + backendOverrideEntry: { + type: 'object', + required: ['name', 'properties'], + additionalProperties: false, + properties: { + name: { type: 'string', description: 'Backend name to match for this override entry.' }, + properties: { $ref: '#/definitions/backendPropertiesObject' }, + }, + }, + backendOverrideSection: { + type: 'array', + description: 'A list of backend override entries.', + items: { $ref: '#/definitions/backendOverrideEntry' }, + }, + + // --- Logger typed section --- + loggerOverrideEntry: { + type: 'object', + required: ['name', 'properties'], + additionalProperties: false, + properties: { + name: { type: 'string', description: 'Logger name to match for this override entry.' }, + properties: { $ref: '#/definitions/loggerPropertiesObject' }, + }, + }, + loggerOverrideSection: { + type: 'array', + description: 'A list of logger override entries.', + items: { $ref: '#/definitions/loggerOverrideEntry' }, + }, + + // --- Diagnostic typed section --- + diagnosticOverrideEntry: { + type: 'object', + required: ['name', 'properties'], + additionalProperties: false, + properties: { + name: { type: 'string', description: 'Diagnostic name to match for this override entry.' }, + properties: { $ref: '#/definitions/diagnosticPropertiesObject' }, + }, + }, + diagnosticOverrideSection: { + type: 'array', + description: 'A list of diagnostic override entries.', + items: { $ref: '#/definitions/diagnosticOverrideEntry' }, + }, + + // --- Policy typed section --- + policyOverrideEntry: { + type: 'object', + required: ['name', 'properties'], + additionalProperties: false, + properties: { + name: { type: 'string', description: 'Policy name to match for this override entry.' }, + properties: { $ref: '#/definitions/policyPropertiesObject' }, + }, + }, + policyOverrideSection: { + type: 'array', + description: 'A list of policy override entries.', + items: { $ref: '#/definitions/policyOverrideEntry' }, + }, + + // --- Operation override --- + operationOverrideEntry: { + type: 'object', + required: ['name', 'properties'], + additionalProperties: false, + properties: { + name: { + type: 'string', + description: 'Operation name to match for this override entry.', + }, + properties: { $ref: '#/definitions/propertiesObject' }, + policies: { + $ref: '#/definitions/policyOverrideSection', + description: + 'Policy overrides nested under this operation. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution.', + }, + }, + }, + operationOverrideSection: { + type: 'array', + description: 'A list of operation override entries.', + items: { $ref: '#/definitions/operationOverrideEntry' }, + }, + + // --- API typed section --- + apiOverrideEntry: { + type: 'object', + required: ['name', 'properties'], + additionalProperties: false, + properties: { + name: { + type: 'string', + description: 'API name to match for this override entry.', + }, + properties: { $ref: '#/definitions/apiPropertiesObject' }, + diagnostics: { + $ref: '#/definitions/diagnosticOverrideSection', + description: + 'Diagnostic overrides nested under this API. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution.', + }, + operations: { + $ref: '#/definitions/operationOverrideSection', + description: + 'Operation overrides nested under this API. Each operation can define its own nested policies. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution.', + }, + policies: { + $ref: '#/definitions/policyOverrideSection', + description: + 'Policy overrides nested directly under this API. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution.', + }, + releases: { + $ref: '#/definitions/overrideSection', + description: + 'Release overrides nested under this API. Values may include {#[TOKEN_NAME]#} placeholders for CI/CD secret token substitution.', + }, + }, + }, + apiOverrideSection: { + type: 'array', + description: 'A list of API override entries.', + items: { $ref: '#/definitions/apiOverrideEntry' }, + }, + + // --- Typed properties objects --- + apiPropertiesObject: { + type: 'object', + description: 'Common API properties with editor autocomplete. Additional API properties are allowed.', + properties: { + displayName: { type: 'string', description: 'Friendly API display name in the APIM portal.' }, + description: { type: ['string', 'null'], description: 'Optional API description.' }, + path: { type: 'string', description: 'API URL suffix/path.' }, + serviceUrl: { type: ['string', 'null'], description: 'Backend service URL for this API.' }, + apiType: { + type: 'string', + description: 'API kind used by APIM import/export logic.', + enum: ['http', 'soap', 'graphql', 'websocket', 'odata', 'grpc', 'mcp', 'a2a'], + }, + type: { + type: 'string', + description: 'Source API type from extracted API metadata. Use the same values as apiType.', + enum: ['http', 'soap', 'graphql', 'websocket', 'odata', 'grpc', 'mcp', 'a2a'], + }, + protocols: { + type: 'array', + description: 'Supported frontend protocols for this API.', + items: { type: 'string', enum: ['http', 'https', 'ws', 'wss'] }, + uniqueItems: true, + }, + subscriptionRequired: { type: 'boolean', description: 'Whether a subscription key is required to call this API.' }, + subscriptionKeyParameterNames: { + type: 'object', + description: 'Custom subscription key header/query names.', + properties: { + header: { type: 'string' }, + query: { type: 'string' }, + }, + additionalProperties: false, + }, + apiRevision: { type: 'string', description: 'API revision identifier.' }, + apiRevisionDescription: { type: ['string', 'null'], description: 'Description for the API revision.' }, + apiVersion: { type: 'string', description: 'API version label.' }, + isCurrent: { type: 'boolean', description: 'Marks this API revision as current.' }, + apiVersionSetId: { type: 'string', description: 'Reference to the API version set resource.' }, + format: { + type: 'string', + description: 'Specification format used for API import/export payloads.', + enum: [ + 'openapi', 'openapi+json', 'openapi-link', + 'swagger-json', 'swagger-link', + 'wsdl', 'wsdl-link', + 'wadl-xml', 'wadl-link', + 'graphql-link', + ], + }, + value: { type: 'string', description: 'Inline specification content or URL depending on format.' }, + authenticationSettings: { + type: 'object', + description: 'Authentication settings for backend authorization.', + additionalProperties: true, + }, + }, + additionalProperties: true, + }, + + namedValuePropertiesObject: { + type: 'object', + description: 'Common named value properties with editor autocomplete. Additional named value properties are allowed.', + properties: { + displayName: { type: 'string', description: 'Friendly named value display name in the APIM portal.' }, + value: { type: ['string', 'null'], description: 'Literal named value content (avoid putting secrets directly in source control).' }, + secret: { type: 'boolean', description: 'Whether the named value is treated as a secret.' }, + tags: { type: 'array', description: 'Named value tags.', items: { type: 'string' }, uniqueItems: true }, + keyVault: { + type: 'object', + description: 'Key Vault secret reference for this named value.', + properties: { + secretIdentifier: { type: 'string', description: 'Full Key Vault secret identifier URL.' }, + identityClientId: { type: ['string', 'null'], description: 'User-assigned managed identity client ID used to access the secret.' }, + }, + additionalProperties: true, + }, + }, + additionalProperties: true, + }, + + backendPropertiesObject: { + type: 'object', + description: 'Common backend properties with editor autocomplete. Additional backend properties are allowed.', + properties: { + title: { type: ['string', 'null'], description: 'Optional backend title.' }, + description: { type: ['string', 'null'], description: 'Optional backend description.' }, + url: { type: ['string', 'null'], description: 'Backend runtime URL.' }, + protocol: { type: 'string', description: 'Backend protocol.', enum: ['http', 'soap'] }, + resourceId: { type: ['string', 'null'], description: 'Linked Azure resource ID, when applicable.' }, + credentials: { type: 'object', description: 'Backend authentication credentials object.', additionalProperties: true }, + proxy: { type: 'object', description: 'Proxy settings for backend connectivity.', additionalProperties: true }, + tls: { type: 'object', description: 'TLS settings for backend connectivity.', additionalProperties: true }, + }, + additionalProperties: true, + }, + + loggerPropertiesObject: { + type: 'object', + description: 'Common logger properties with editor autocomplete. Additional logger properties are allowed.', + properties: { + loggerType: { + type: 'string', + description: 'Logger type.', + enum: ['applicationInsights', 'azureEventHub'], + }, + description: { type: ['string', 'null'], description: 'Optional logger description.' }, + resourceId: { type: ['string', 'null'], description: 'Linked Azure resource ID for the logger target.' }, + isBuffered: { type: 'boolean', description: 'Whether messages are buffered before being sent.' }, + credentials: { + type: 'object', + description: 'Logger credentials (for example instrumentationKey or connection string).', + properties: { + instrumentationKey: { type: 'string', description: 'Application Insights instrumentation key or named value reference.' }, + name: { type: 'string', description: 'Event Hub name, where applicable.' }, + connectionString: { type: 'string', description: 'Event Hub connection string or named value reference.' }, + }, + additionalProperties: true, + }, + }, + additionalProperties: true, + }, + + diagnosticPropertiesObject: { + type: 'object', + description: 'Common diagnostic properties with editor autocomplete. Additional diagnostic properties are allowed.', + properties: { + alwaysLog: { + type: ['string', 'null'], + description: 'Diagnostic always-log behavior.', + enum: ['allErrors', 'always', null], + }, + httpCorrelationProtocol: { + type: ['string', 'null'], + description: 'HTTP correlation protocol for tracing.', + enum: ['Legacy', 'W3C', 'None', null], + }, + logClientIp: { type: 'boolean', description: 'Whether client IP address is logged.' }, + verbosity: { + type: 'string', + description: 'Diagnostic verbosity level.', + enum: ['verbose', 'information', 'error', 'Verbose', 'Information', 'Error'], + }, + loggerId: { type: ['string', 'null'], description: 'Target logger ARM resource ID.' }, + sampling: { + type: 'object', + description: 'Diagnostic sampling configuration.', + properties: { + samplingType: { type: 'string', enum: ['fixed'] }, + percentage: { type: 'number' }, + }, + additionalProperties: true, + }, + frontend: { type: ['object', 'null'], description: 'Frontend request/response diagnostic settings.', additionalProperties: true }, + backend: { type: ['object', 'null'], description: 'Backend request/response diagnostic settings.', additionalProperties: true }, + largeLanguageModel: { type: ['object', 'null'], description: 'LLM diagnostic settings.', additionalProperties: true }, + tags: { type: ['object', 'null'], description: 'Diagnostic tags.', additionalProperties: true }, + }, + additionalProperties: true, + }, + + policyPropertiesObject: { + type: 'object', + description: 'Common policy properties with editor autocomplete. Additional policy properties are allowed.', + properties: { + format: { + type: 'string', + description: 'Policy content format.', + enum: ['rawxml', 'rawxml-link', 'xml', 'xml-link'], + }, + value: { type: 'string', description: 'Inline policy XML or linked value, depending on format.' }, + }, + additionalProperties: true, + }, + }, +}; + +// Write schemas +await mkdir(schemasDir, { recursive: true }); +await writeFile( + resolve(schemasDir, 'extractor-config.schema.json'), + JSON.stringify(extractorSchema, null, 2) + '\n' +); +await writeFile( + resolve(schemasDir, 'override-config.schema.json'), + JSON.stringify(overrideSchema, null, 2) + '\n' +); diff --git a/src/lib/render-template.ts b/src/lib/render-template.ts new file mode 100644 index 00000000..65b9a766 --- /dev/null +++ b/src/lib/render-template.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Template rendering utility. + * Replaces {{TOKEN}} placeholders in template strings with provided values. + */ + +/** + * Replaces all `{{KEY}}` placeholders in a template string with the + * corresponding values from the tokens map. + */ +export function renderTemplate(template: string, tokens: Record): string { + return Object.entries(tokens).reduce( + (rendered, [key, value]) => rendered.replaceAll(`{{${key}}}`, value), + template + ); +} diff --git a/src/services/identity-guide-service.ts b/src/services/identity-guide-service.ts index 474d5731..366c1839 100644 --- a/src/services/identity-guide-service.ts +++ b/src/services/identity-guide-service.ts @@ -11,6 +11,7 @@ import { azureDevOpsIdentityGuideTemplate, githubActionsIdentityGuideTemplate, } from '../templates/generated/embedded-markdown.js'; +import { renderTemplate } from '../lib/render-template.js'; export interface IdentityGuideService { generateGitHubActionsGuide( @@ -25,13 +26,6 @@ export interface IdentityGuideService { } class IdentityGuideServiceImpl implements IdentityGuideService { - private renderTemplate(template: string, tokens: Record): string { - return Object.entries(tokens).reduce( - (rendered, [key, value]) => rendered.replaceAll(`{{${key}}}`, value), - template - ); - } - generateGitHubActionsGuide( subscriptionId: string, resourceGroup: string, @@ -52,7 +46,7 @@ class IdentityGuideServiceImpl implements IdentityGuideService { - \`APIM_SERVICE_NAME_${env.toUpperCase()}\`: APIM service name for ${env} `).join('\n'); - return this.renderTemplate(githubActionsIdentityGuideTemplate, { + return renderTemplate(githubActionsIdentityGuideTemplate, { SUBSCRIPTION_ID: subscriptionId, RESOURCE_GROUP: resourceGroup, FEDERATED_CREDENTIALS_PER_ENV: federatedCredentialsPerEnvironment, @@ -70,12 +64,12 @@ class IdentityGuideServiceImpl implements IdentityGuideService { .map((environment) => `"${environment}"`) .join(' '); - const coreSteps = this.renderTemplate(azureDevOpsIdentitySetupCoreTemplate, { + const coreSteps = renderTemplate(azureDevOpsIdentitySetupCoreTemplate, { ENVIRONMENTS_ARRAY_POWERSHELL: environmentsArrayPowerShell, ENVIRONMENTS_ARRAY_BASH: environmentsArrayBash, }); - return this.renderTemplate(azureDevOpsIdentityGuideTemplate, { + return renderTemplate(azureDevOpsIdentityGuideTemplate, { AZURE_DEVOPS_CORE_STEPS: coreSteps, }); } diff --git a/src/services/init-service.ts b/src/services/init-service.ts index 9ed311fc..210d6637 100644 --- a/src/services/init-service.ts +++ b/src/services/init-service.ts @@ -32,6 +32,8 @@ import { generateFilterConfig } from '../templates/configs/filter-config.js'; import { generateOverrideConfig } from '../templates/configs/override-config.js'; import { generatePackageJson } from '../templates/configs/package-json.js'; import { generateIdentitySetupPrompt } from '../templates/copilot/identity-setup-prompt.js'; +import { generateConfigureFilterPrompt } from '../templates/copilot/configure-filter-prompt.js'; +import { generateConfigureOverridesPrompt } from '../templates/copilot/configure-overrides-prompt.js'; /** Placeholder values used in generated identity setup guides */ const PLACEHOLDER_SUBSCRIPTION_ID = ''; @@ -146,6 +148,14 @@ class InitServiceImpl implements InitService { config.outputDir, '.github/prompts/apiops-setup-identity.prompt.md' ); + const filterPromptFile = path.join( + config.outputDir, + '.github/prompts/apiops-configure-filter.prompt.md' + ); + const overridesPromptFile = path.join( + config.outputDir, + '.github/prompts/apiops-configure-overrides.prompt.md' + ); const identityGuide = path.join( config.outputDir, 'IDENTITY-SETUP-GITHUB.md' @@ -160,6 +170,12 @@ class InitServiceImpl implements InitService { if (await this.fileExists(promptFile)) { conflictingFiles.push(promptFile); } + if (await this.fileExists(filterPromptFile)) { + conflictingFiles.push(filterPromptFile); + } + if (await this.fileExists(overridesPromptFile)) { + conflictingFiles.push(overridesPromptFile); + } if (await this.fileExists(identityGuide)) { conflictingFiles.push(identityGuide); } @@ -180,6 +196,14 @@ class InitServiceImpl implements InitService { config.outputDir, '.github/prompts/apiops-setup-identity.prompt.md' ); + const filterPromptFile = path.join( + config.outputDir, + '.github/prompts/apiops-configure-filter.prompt.md' + ); + const overridesPromptFile = path.join( + config.outputDir, + '.github/prompts/apiops-configure-overrides.prompt.md' + ); if (await this.fileExists(extractPipeline)) { conflictingFiles.push(extractPipeline); @@ -193,6 +217,12 @@ class InitServiceImpl implements InitService { if (await this.fileExists(promptFile)) { conflictingFiles.push(promptFile); } + if (await this.fileExists(filterPromptFile)) { + conflictingFiles.push(filterPromptFile); + } + if (await this.fileExists(overridesPromptFile)) { + conflictingFiles.push(overridesPromptFile); + } } // Check for config files @@ -317,6 +347,7 @@ class InitServiceImpl implements InitService { generatedFiles.pipelines.push('.github/workflows/run-apim-publisher.yml'); await this.generateCopilotIdentitySetupPrompt(config, generatedFiles); + await this.generateCopilotConfigurationPrompts(config, generatedFiles); } /** @@ -350,6 +381,7 @@ class InitServiceImpl implements InitService { generatedFiles.pipelines.push('.azdo/pipelines/run-apim-publisher.yml'); await this.generateCopilotIdentitySetupPrompt(config, generatedFiles); + await this.generateCopilotConfigurationPrompts(config, generatedFiles); } private async generateCopilotIdentitySetupPrompt( @@ -367,6 +399,28 @@ class InitServiceImpl implements InitService { generatedFiles.configs.push('.github/prompts/apiops-setup-identity.prompt.md'); } + private async generateCopilotConfigurationPrompts( + config: InitConfig, + generatedFiles: GeneratedFiles + ): Promise { + const promptsDir = path.join(config.outputDir, '.github/prompts'); + await fs.mkdir(promptsDir, { recursive: true }); + + // Filter configuration prompt + const filterPromptContent = generateConfigureFilterPrompt({ + environments: config.environments, + }); + const filterPromptPath = path.join(promptsDir, 'apiops-configure-filter.prompt.md'); + await fs.writeFile(filterPromptPath, filterPromptContent); + generatedFiles.configs.push('.github/prompts/apiops-configure-filter.prompt.md'); + + // Override configuration prompt + const overridesPromptContent = generateConfigureOverridesPrompt(); + const overridesPromptPath = path.join(promptsDir, 'apiops-configure-overrides.prompt.md'); + await fs.writeFile(overridesPromptPath, overridesPromptContent); + generatedFiles.configs.push('.github/prompts/apiops-configure-overrides.prompt.md'); + } + /** * Generate configuration files */ diff --git a/src/templates/configs/filter-config.ts b/src/templates/configs/filter-config.ts index a992c12b..96f62fb0 100644 --- a/src/templates/configs/filter-config.ts +++ b/src/templates/configs/filter-config.ts @@ -5,123 +5,12 @@ * Generates a sample configuration.extractor.yaml file */ -export function generateFilterConfig(): string { - return `# APIM Extract Filter Configuration -# Customize this file to control which resources are extracted -# For full format details and examples, see: -# https://github.com/Azure/apiops-cli/blob/main/docs/guides/filtering-resources.md - -# Extract only specific APIs by name (or wildcard pattern) -# apis: -# - echo-api -# - petstore-api -# - 'prod-*' # Wildcard: all APIs starting with prod- -# - '*-internal-*' # Wildcard: all APIs containing -internal- - -# Advanced: Filter API sub-resources (operations, diagnostics, schemas, releases) -# apis: -# - echo-api # Include all sub-resources -# - petstore-api: # Control sub-resources -# operations: -# - get-pets -# - create-pet -# - 'list-*' # Wildcard: all operations starting with list- -# diagnostics: -# - applicationinsights -# schemas: [] # Exclude all schemas -# releases: -# - v1 - -# Extract only specific products -# products: -# - starter -# - unlimited - -# Extract only specific backends -# backends: -# - backend-api -# - legacy-backend - -# Extract only specific named values -# namedValues: -# - api-key -# - connection-string - -# Extract only specific loggers -# loggers: -# - appinsights-logger - -# Extract only specific diagnostics -# diagnostics: -# - applicationinsights - -# Extract only specific tags -# tags: -# - production -# - external - -# Extract only specific policy fragments -# policyFragments: -# - rate-limit-fragment -# - cors-fragment - -# Extract only specific gateways -# gateways: -# - default -# - internal-gateway +import { filterConfigTemplate } from '../generated/embedded-markdown.js'; +import { renderTemplate } from '../../lib/render-template.js'; +import { schemaUrl } from './schema-ref.js'; -# Extract only specific version sets -# versionSets: -# - payments-v1 - -# Extract only specific groups -# groups: -# - administrators - -# Extract only specific subscriptions -# subscriptions: -# - starter-subscription - -# Extract only specific schemas -# schemas: -# - pet-schema - -# Filter service-level policies -# policies: -# - policy - -# Extract only specific policy restrictions -# policyRestrictions: -# - global-policy-restriction - -# Extract only specific documentations -# documentations: -# - getting-started - -# Extract only specific workspaces -# workspaces: -# - dev-workspace - -# Advanced: Filter workspace sub-resources -# workspaces: -# - team-workspace: -# apis: -# - team-api-1 -# - team-api-2 -# backends: -# - team-backend -# namedValues: -# - team-api-key - -# Filter behavior: -# - Leave a section commented out to include ALL resources of that type -# - Set a section to an empty array ([]) to exclude ALL resources of that type -# Example: -# gateways: [] -# subscriptions: [] -# - Use * to match any characters: prod-* matches prod-api, prod-users -# - Use ? to match a single character: api-v? matches api-v1, api-v2 -# - Exact names and wildcard patterns can be mixed in the same list -# - All matching is case-insensitive -`; +export function generateFilterConfig(): string { + return renderTemplate(filterConfigTemplate, { + SCHEMA_URL: schemaUrl('extractor-config.schema.json'), + }); } diff --git a/src/templates/configs/filter-config.yaml b/src/templates/configs/filter-config.yaml new file mode 100644 index 00000000..7a935ebd --- /dev/null +++ b/src/templates/configs/filter-config.yaml @@ -0,0 +1,118 @@ +# yaml-language-server: $schema={{SCHEMA_URL}} +# APIM Extract Filter Configuration +# Customize this file to control which resources are extracted +# For full format details and examples, see: +# https://github.com/Azure/apiops-cli/blob/main/docs/guides/filtering-resources.md + +# Extract only specific APIs by name (or wildcard pattern) +# apis: +# - echo-api +# - petstore-api +# - 'prod-*' # Wildcard: all APIs starting with prod- +# - '*-internal-*' # Wildcard: all APIs containing -internal- + +# Advanced: Filter API sub-resources (operations, diagnostics, schemas, releases) +# apis: +# - echo-api # Include all sub-resources +# - petstore-api: # Control sub-resources +# operations: +# - get-pets +# - create-pet +# - 'list-*' # Wildcard: all operations starting with list- +# diagnostics: +# - applicationinsights +# schemas: [] # Exclude all schemas +# releases: +# - v1 + +# Extract only specific products +# products: +# - starter +# - unlimited + +# Extract only specific backends +# backends: +# - backend-api +# - legacy-backend + +# Extract only specific named values +# namedValues: +# - api-key +# - connection-string + +# Extract only specific loggers +# loggers: +# - appinsights-logger + +# Extract only specific diagnostics +# diagnostics: +# - applicationinsights + +# Extract only specific tags +# tags: +# - production +# - external + +# Extract only specific policy fragments +# policyFragments: +# - rate-limit-fragment +# - cors-fragment + +# Extract only specific gateways +# gateways: +# - default +# - internal-gateway + +# Extract only specific version sets +# versionSets: +# - payments-v1 + +# Extract only specific groups +# groups: +# - administrators + +# Extract only specific subscriptions +# subscriptions: +# - starter-subscription + +# Extract only specific schemas +# schemas: +# - pet-schema + +# Filter service-level policies +# policies: +# - policy + +# Extract only specific policy restrictions +# policyRestrictions: +# - global-policy-restriction + +# Extract only specific documentations +# documentations: +# - getting-started + +# Extract only specific workspaces +# workspaces: +# - dev-workspace + +# Advanced: Filter workspace sub-resources +# workspaces: +# - team-workspace: +# apis: +# - team-api-1 +# - team-api-2 +# backends: +# - team-backend +# namedValues: +# - team-api-key + +# Filter behavior: +# - Leave a section commented out to include ALL resources of that type +# - Set a section to an empty array ([]) to exclude ALL resources of that type +# Example: +# gateways: [] +# subscriptions: [] +# - Use * to match any characters: prod-* matches prod-api, prod-users +# - Use ? to match a single character: api-v? matches api-v1, api-v2 +# - Exact names and wildcard patterns can be mixed in the same list +# - All matching is case-insensitive diff --git a/src/templates/configs/override-config.ts b/src/templates/configs/override-config.ts index 0aae9a58..efb0b9e8 100644 --- a/src/templates/configs/override-config.ts +++ b/src/templates/configs/override-config.ts @@ -5,97 +5,13 @@ * Generates environment-specific configuration.{env}.yaml files */ -export function generateOverrideConfig(environment: string): string { - return `# APIM Override Configuration for ${environment} environment -# Customize resource properties for this specific environment -# For full format details and examples, see: -# https://github.com/Azure/apiops-cli/blob/main/docs/guides/environment-overrides.md - -# Override named values (e.g., API keys, connection strings) -# namedValues: -# - name: api-key -# properties: -# value: "${environment}-api-key-value" -# - name: connection-string -# properties: -# value: "{#[DB_Connection_String]#}" -# - name: secret-from-keyvault -# properties: -# keyVault: -# secretIdentifier: "https://${environment}-kv.vault.azure.net/secrets/my-secret" -# identityClientId: "00000000-0000-0000-0000-000000000000" - -# Override backend URLs per environment -# backends: -# - name: backend-api -# properties: -# url: "https://${environment}-api.example.com" -# - name: legacy-backend -# properties: -# url: "https://${environment}-legacy.example.com" -# resourceId: "/subscriptions/.../sites/${environment}-backend" - -# Override API service URLs (with optional nested sub-resource overrides) -# apis: -# - name: echo-api -# properties: -# serviceUrl: "https://${environment}-echo.example.com" -# - name: petstore-api -# properties: -# serviceUrl: "https://${environment}-petstore.example.com" -# displayName: "Petstore API (${environment})" -# diagnostics: -# - name: applicationinsights -# properties: -# loggerId: "appinsights-logger-${environment}" -# verbosity: Error -# policies: -# - name: policy -# properties: -# format: rawxml - -# Override diagnostic logger references -# diagnostics: -# - name: applicationinsights -# properties: -# loggerId: "appinsights-logger-${environment}" -# verbosity: Error +import { overrideConfigTemplate } from '../generated/embedded-markdown.js'; +import { renderTemplate } from '../../lib/render-template.js'; +import { schemaUrl } from './schema-ref.js'; -# Override logger credentials or resource IDs -# loggers: -# - name: appinsights-logger -# properties: -# loggerType: applicationInsights -# resourceId: "/subscriptions/xxxxx/resourceGroups/${environment}-rg/providers/microsoft.insights/components/${environment}-appinsights" -# isBuffered: true -# credentials: -# instrumentationKey: "" - -# Override service-level policies -# policies: -# - name: policy -# properties: -# format: rawxml - -# Override gateway properties -# gateways: -# - name: on-prem-gateway -# properties: -# locationData: -# name: "${environment} datacenter" - -# Override version sets, groups, subscriptions, products, tags, policy fragments -# versionSets: -# - name: my-version-set -# properties: -# displayName: "My Version Set (${environment})" -# products: -# - name: starter -# properties: -# displayName: "Starter Plan (${environment})" -# tags: -# - name: env-tag -# properties: -# displayName: "${environment}" -`; +export function generateOverrideConfig(environment: string): string { + return renderTemplate(overrideConfigTemplate, { + SCHEMA_URL: schemaUrl('override-config.schema.json'), + ENVIRONMENT: environment, + }); } diff --git a/src/templates/configs/override-config.yaml b/src/templates/configs/override-config.yaml new file mode 100644 index 00000000..369e04a9 --- /dev/null +++ b/src/templates/configs/override-config.yaml @@ -0,0 +1,92 @@ +# yaml-language-server: $schema={{SCHEMA_URL}} +# APIM Override Configuration for {{ENVIRONMENT}} environment +# Customize resource properties for this specific environment +# For full format details and examples, see: +# https://github.com/Azure/apiops-cli/blob/main/docs/guides/environment-overrides.md + +# Override named values (e.g., API keys, connection strings) +# namedValues: +# - name: api-key +# properties: +# value: "{{ENVIRONMENT}}-api-key-value" +# - name: connection-string +# properties: +# value: "{#[DB_Connection_String]#}" +# - name: secret-from-keyvault +# properties: +# keyVault: +# secretIdentifier: "https://{{ENVIRONMENT}}-kv.vault.azure.net/secrets/my-secret" +# identityClientId: "00000000-0000-0000-0000-000000000000" + +# Override backend URLs per environment +# backends: +# - name: backend-api +# properties: +# url: "https://{{ENVIRONMENT}}-api.example.com" +# - name: legacy-backend +# properties: +# url: "https://{{ENVIRONMENT}}-legacy.example.com" +# resourceId: "/subscriptions/.../sites/{{ENVIRONMENT}}-backend" + +# Override API service URLs (with optional nested sub-resource overrides) +# apis: +# - name: echo-api +# properties: +# serviceUrl: "https://{{ENVIRONMENT}}-echo.example.com" +# - name: petstore-api +# properties: +# serviceUrl: "https://{{ENVIRONMENT}}-petstore.example.com" +# displayName: "Petstore API ({{ENVIRONMENT}})" +# diagnostics: +# - name: applicationinsights +# properties: +# loggerId: "appinsights-logger-{{ENVIRONMENT}}" +# verbosity: Error +# policies: +# - name: policy +# properties: +# format: rawxml + +# Override diagnostic logger references +# diagnostics: +# - name: applicationinsights +# properties: +# loggerId: "appinsights-logger-{{ENVIRONMENT}}" +# verbosity: Error + +# Override logger credentials or resource IDs +# loggers: +# - name: appinsights-logger +# properties: +# loggerType: applicationInsights +# resourceId: "/subscriptions/xxxxx/resourceGroups/{{ENVIRONMENT}}-rg/providers/microsoft.insights/components/{{ENVIRONMENT}}-appinsights" +# isBuffered: true +# credentials: +# instrumentationKey: "" + +# Override service-level policies +# policies: +# - name: policy +# properties: +# format: rawxml + +# Override gateway properties +# gateways: +# - name: on-prem-gateway +# properties: +# locationData: +# name: "{{ENVIRONMENT}} datacenter" + +# Override version sets, groups, subscriptions, products, tags, policy fragments +# versionSets: +# - name: my-version-set +# properties: +# displayName: "My Version Set ({{ENVIRONMENT}})" +# products: +# - name: starter +# properties: +# displayName: "Starter Plan ({{ENVIRONMENT}})" +# tags: +# - name: env-tag +# properties: +# displayName: "{{ENVIRONMENT}}" diff --git a/src/templates/configs/schema-ref.ts b/src/templates/configs/schema-ref.ts new file mode 100644 index 00000000..3b26cb08 --- /dev/null +++ b/src/templates/configs/schema-ref.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Builds URLs to the published JSON schemas. + * + * The schema is only an editor/IDE validation aid (yaml-language-server); the + * CLI does its own validation at runtime. Schemas are versioned independently + * of the CLI package version: each schema version lives at a frozen path + * (`schemas/v/...`) on the `main` branch. Backward-compatible edits update + * the current version in place; a breaking change introduces a new version + * folder. The `main` ref always resolves, and the versioned path keeps existing + * config files pointing at the schema shape they were written against. + */ + +import packageJson from '../../../package.json' with { type: 'json' }; + +const SCHEMA_BASE = 'https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas'; + +/** + * Returns the raw URL for a published schema file at the current schema version. + * + * @param fileName Schema file name, e.g. `extractor-config.schema.json`. + */ +export function schemaUrl(fileName: string): string { + const { schemaVersion } = packageJson as { schemaVersion?: string }; + const version = schemaVersion ?? '1'; + return `${SCHEMA_BASE}/v${version}/${fileName}`; +} diff --git a/src/templates/copilot/configure-filter-prompt.md b/src/templates/copilot/configure-filter-prompt.md new file mode 100644 index 00000000..c29ba6d9 --- /dev/null +++ b/src/templates/copilot/configure-filter-prompt.md @@ -0,0 +1,195 @@ +--- +mode: 'agent' +description: 'Configure APIOps resource extraction filters' +--- + +# Configure APIOps Extractor Filters + +> **How to use:** Open this file in VS Code with GitHub Copilot and ask +> Copilot to help you design a `configuration.extractor.yaml` file for your +> repository. + +## Goal + +Create a `configuration.extractor.yaml` file that limits APIOps extraction to +the Azure API Management resources your team wants to manage in source control. + +--- + +## How Copilot must work through this prompt + +These rules apply to **every** step below. Follow them strictly: + +1. **Confirm before proceeding.** At the end of every step, summarize what you + learned or propose, then **STOP and wait for the user to confirm** before + moving to the next step. Never chain steps together without an explicit + "yes" / "go ahead" from the user. + - **Hard stop rule:** When you ask for confirmation, end the response there. + Do **not** include the next question, next resource type, or any forward + action in the same message. + - This hard stop applies to **step boundaries** (Step 0, Step 2, Step 3, + Step 4). In Step 1, follow the single-resource-type cadence below. +2. **Never assume or invent names.** Do not invent API, product, backend, + named value, or any other resource names. Use only names that come from the + live APIM instance or that the user explicitly provides. The local artifact + directory is not authoritative — it may be stale or empty. When unsure, ask. +3. **Default is extract-everything.** APIOps extracts **all** resources of a + type when that type is **omitted** from the filter. Only add a type to the + filter when the user wants to narrow it (SOME) or exclude it (NONE). Do not + add a type just to list every resource. +4. **Empty array means exclude all.** Setting a type to `[]` excludes every + resource of that type. Use this only when the user explicitly wants NONE. +5. **The JSON schema is the source of structure.** To determine which resource + types support sub-entries and what those sub-entries are (for example, + `apis` → `operations`, `diagnostics`, `schemas`, `releases`), consult the + `extractor-config` JSON schema referenced in the file's + `# yaml-language-server: $schema=...` comment (the public schema URL). + +--- + +## Step 0 — Determine the Authoritative Resource List + +The filter runs at **extraction time against the live Azure API Management +instance**. The local artifact directory may be stale, partial, or empty, so it +is **not** an authoritative list of what exists in Azure. Establish the source +of truth first: + +1. **Prefer querying the live APIM instance.** Ask the user for (or reuse if + already known) the subscription ID, resource group, and APIM service name, + and whether the Azure CLI is logged in. If Azure is reachable, enumerate the + resource types and names directly from the instance (for example with + `az apim` / `az rest` calls) and use that as the source of truth. +2. **Fallback when Azure cannot be queried.** Do **not** treat the local + artifacts as the definitive list. Instead, in Step 1 ask the user + type-by-type; for SOME, the user must provide the resource (and + sub-resource) names themselves. +3. Check whether `configuration.extractor.yaml` already exists (it may have + been created by `apiops init`). If it exists, note its current contents — + you will update it in place rather than overwriting it. + +Tell the user which mode you will use (live-Azure list vs. user-provided +names), and confirm the connection details if querying Azure. + +**STOP. Do not proceed until the user confirms the source of truth.** + +--- + +## Step 1 — Decide Scope Per Resource Type (one type at a time) + +Walk through the resource types **one type at a time**. For each type, ask the +user which scope they want: + +- **Extract ALL** — include every resource of this type. Leave this type + **out** of the filter (APIOps extracts everything by default). +- **Extract NONE** — exclude all resources of this type. Add the type with an + empty array: `tags: []`. +- **Extract SOME** — include only specific resources. The user provides which + names (or wildcard patterns) to include. Matching is case-insensitive and + supports `*` and `?` wildcards. + +**Single-resource-type cadence for Step 1:** + +- Ask about exactly **one** resource type at a time. Do not batch multiple + types into one prompt. +- **Ask ALL / NONE / SOME first.** Do **not** enumerate or query any names up + front. For ALL or NONE, record the answer and move on — no enumeration is + needed. +- **Only when the user answers SOME**, then gather names: + - If you can query the live APIM instance, list that type's names from Azure + to help the user choose. + - Otherwise, ask the user to provide the names/patterns. Do **not** invent + names or pull them from the local artifacts. +- When the user answers a type unambiguously, record the decision and move to + the next type. +- **Update `configuration.extractor.yaml` immediately after each decision that + affects the file** (SOME or NONE adds/updates that type's section; ALL means + no change since the type is omitted). Keep the file in sync as you go rather + than waiting until the end. +- Only pause for clarification when the answer is ambiguous. + +Resource types to consider (ask only about types that exist in the +instance/artifacts or that the user mentions): + +`apis`, `products`, `namedValues`, `backends`, `loggers`, `diagnostics`, +`tags`, `versionSets`, `policyFragments`, `gateways`, `groups`, +`subscriptions`, `schemas`, `policies`, `workspaces`. + +> **APIs can be filtered at the sub-resource level.** Whenever `apis` is SOME +> and specific API names are listed in the filter, **ask about each listed +> API's sub-resources** — `operations`, `diagnostics`, `schemas`, and +> `releases`. The user may want everything for that API, or only a subset +> (for example, a single revision or release). Omit a sub-filter to include +> all of that sub-type; set it to `[]` to exclude all. + +> **Workspaces apply only if the APIM instance uses workspaces.** Skip this +> type entirely if there are no workspaces. When a user wants SOME for +> `workspaces`, each workspace can also be narrowed by its own sub-resources +> (`apis`, `backends`, `diagnostics`, `groups`, `loggers`, `namedValues`, +> `policyFragments`, `products`, `schemas`, `subscriptions`, `tags`, +> `versionSets`). Omit a sub-filter to include all of that sub-type; set it to +> `[]` to exclude all. Only offer this depth if the user wants it. + +> **Service-level `policies` is effectively a single global policy.** For this +> type, ask only **include (ALL)** or **exclude (NONE)** — SOME does not apply. + +After all types are decided, summarize the per-type decisions and **STOP for +confirmation** before generating YAML. + +--- + +## Step 2 — Propose a Filter Strategy + +Based on the recorded decisions: + +1. Recommend the smallest filter that safely captures the intended scope + (remember: omitted types are fully extracted, so only NONE/SOME types + appear in the file). +2. Explain any tradeoffs between broad and narrow filters. +3. Call out any risk of accidentally excluding required dependencies — for + example, excluding a named value or backend that an included API's policy + references. + +If the user is unsure, recommend a conservative filter that is easy to refine, +then **STOP for confirmation**. + +--- + +## Step 3 — Generate `configuration.extractor.yaml` + +> **Note:** The file `configuration.extractor.yaml` may already exist if the +> user ran `apiops init`. If it exists, **update it in place** rather than +> overwriting unrelated content. + +Create the YAML file content reflecting the confirmed decisions. + +Requirements: + +- **Preserve the existing schema comment.** If the file already has a + `# yaml-language-server: $schema=...` line (as `apiops init` generates), keep + it **exactly as-is** — it already points at the correct schema version. Only + if the file has **no** schema comment, add one referencing the current schema + version: + `# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas/v1/extractor-config.schema.json` +- Output valid YAML. +- Only include resource types the user chose to narrow (SOME) or exclude + (NONE). Leave ALL types out of the file. +- Use only names/patterns that exist in the artifacts or that the user + provided — do not invent names. +- Add a short comment only when it explains a non-obvious choice. + +Show the generated file and **STOP for confirmation** before treating it as +final. + +--- + +## Step 4 — Validate the Result + +Before finishing: + +1. Review the generated YAML for syntax issues and schema validity. +2. Confirm the filters align with the user's intended extraction scope, and + that no type the user wanted is accidentally excluded or fully extracted. +3. Remind the user to run the extractor and inspect the artifact output. + +If the extractor output is too broad or too narrow, help the user refine the +filter file iteratively. diff --git a/src/templates/copilot/configure-filter-prompt.ts b/src/templates/copilot/configure-filter-prompt.ts new file mode 100644 index 00000000..0ab7c4fa --- /dev/null +++ b/src/templates/copilot/configure-filter-prompt.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * GitHub Copilot prompt template for configuring resource filters. + * Generates a .prompt.md file that guides Copilot through creating + * a configuration.extractor.yaml filter file. + */ + +import { copilotConfigureFilterPromptTemplate } from '../generated/embedded-markdown.js'; + +export interface ConfigureFilterPromptConfig { + environments: string[]; +} + +export function generateConfigureFilterPrompt(_config: ConfigureFilterPromptConfig): string { + // The filter prompt is static — no token substitution needed currently. + // The config parameter is accepted for future extensibility and consistency. + return copilotConfigureFilterPromptTemplate; +} diff --git a/src/templates/copilot/configure-overrides-prompt.md b/src/templates/copilot/configure-overrides-prompt.md new file mode 100644 index 00000000..7bbdedd8 --- /dev/null +++ b/src/templates/copilot/configure-overrides-prompt.md @@ -0,0 +1,299 @@ +--- +mode: 'agent' +description: 'Configure APIOps environment overrides' +--- + +# Configure APIOps Environment Overrides + +> **How to use:** Open this file in VS Code with GitHub Copilot and ask +> Copilot to help you create environment-specific APIOps override files. + +## Goal + +Create one `configuration.{environment}.yaml` file per deployment environment +so APIOps publish runs can promote the same artifacts across environments with +environment-specific settings. + +--- + +## How Copilot must work through this prompt + +These rules apply to **every** step below. Follow them strictly: + +1. **Confirm before proceeding.** At the end of every step, summarize what you + learned or propose, then **STOP and wait for the user to confirm** before + moving to the next step. Never chain steps together without an explicit + "yes" / "go ahead" from the user. + - **Hard stop rule:** When you ask for confirmation, end the response there. + Do **not** include the next question, next override, or any forward action + in the same message. + - This hard stop applies to **step boundaries** (Step 0, Step 1, Step 2, + Step 3, Step 5, Step 6). In Step 4, follow the single-setting cadence below. +2. **Never assume a value.** Do not invent backend URLs, service URLs, + resource IDs, instrumentation keys, secret names, Key Vault URLs, or token + names. If you don't know a value, **ask the user**. +3. **Do not tokenize everything.** A `{#[TOKEN_NAME]#}` placeholder is only for + values the user explicitly wants injected by the pipeline (see Step 4 for + how to classify each value). Many values are plain, non-sensitive settings + that should be written literally. +4. **Ask, don't guess, about pipeline tokens.** Only use a token after the user + has told you that token exists (or will be added) in their pipeline. +5. **The JSON schema is the source of structure.** To determine the valid + shape of an override entry and its nested properties (for example a Key + Vault named value's `keyVault.secretIdentifier` / `identityClientId`), + consult the `override-config` JSON schema referenced in each file's + `# yaml-language-server: $schema=...` comment (the public schema URL). Do + **not** rely on the `apiops-cli` source repository — end users only have + the built npm package and the published schema URL. + +--- + +## Step 0 — Detect and Confirm Environments + +Before asking the user anything else, look for existing environment +configuration files in the repository: + +1. Search for files matching `configuration.*.yaml` (excluding + `configuration.extractor.yaml`). The `*` portion is the environment name. +2. Also check CI/CD workflow files (`.github/workflows/` or + `.azdo/pipelines/`) for environment references. + +Then **present the detected environments to the user** and ask which ones they +want override files for: + +> "I found these environments: ``. Which of these do you want to create +> or update override files for? If you deploy to other environments I didn't +> detect, list them too." + +If no config files are found, ask: +> "What environments do you deploy to? Common patterns include `dev, stage, prod` +> or `stage, prod` (if dev shares the same APIM instance as stage)." + +**STOP. Do not proceed until the user has explicitly confirmed the exact list +of environments to work on.** + +--- + +## Step 1 — Gather Information + +Once the environment list is confirmed, collect the following: + +1. **Existing override config files** — If `configuration.{env}.yaml` files + already exist: + - Use those as the starting point. + - Ask whether the user wants to update them or start fresh. + +2. **APIM artifacts location** — Ask the user where the APIOps artifact + directory is (default: `./apim-artifacts`). You will inspect the artifacts + in the next step. + +Summarize what you've learned and **STOP for confirmation** before continuing. + +--- + +## Step 2 — Investigate Artifacts and Create Stub Override Files + +Using the artifact directory confirmed in Step 1: + +1. Scan the artifacts for references to **external resources** — the things + that typically differ between environments. Examples: + - API `serviceUrl` values + - Backend service URLs and linked `resourceId`s + - Named values (secrets and plain config values) + - Logger `resourceId`s and credentials + - Diagnostic `loggerId` references + - Gateway or VNet references + - Policy fragment references to external endpoints + - Workspace-scoped resources (only if the APIM instance uses **workspaces**) + — workspaces can contain their own APIs, backends, named values, loggers, + etc. that may need per-environment overrides + + > **Note:** References to sub-resources of the same APIM instance (e.g., + > one API referencing another API's policy) are handled automatically by + > APIOps and do **not** need overrides. + +2. Produce a **plain list of override candidates** grouped by resource type + (e.g., "APIs needing a serviceUrl: `src-graphql-passthrough`, + `src-rest-versioned-v1`…"). Do **not** decide yet which are tokens versus + literals — that happens in Step 3. + +3. Present this list and ask the user to confirm which items actually need + per-environment overrides and which can be left as-is. + +4. Once the candidate list is confirmed, **create the stub override files** — + one `configuration.{env}.yaml` per confirmed environment — containing every + confirmed candidate as an entry with the correct `name` and structure but + **blank values** (e.g., empty strings `""` or empty `properties`). This + shows the shape of each file; the actual values are filled in during + Step 4. **Preserve any existing schema comment.** If a file already has a + `# yaml-language-server: $schema=...` line (as `apiops init` generates), + keep it **exactly as-is** — it already points at the correct schema version. + Only when creating a brand-new file with no schema comment, add one + referencing the current schema version: + `# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/apiops-cli/main/schemas/v1/override-config.schema.json` + +**STOP for confirmation before continuing to Step 3.** + +--- + +## Step 3 — Confirm Available Pipeline Tokens + +Before filling in any value, **ask the user** which environment variables / +pipeline variables are available for this environment, and get the **exact, +case-sensitive** token names. + +**Known `apiops init` tokens.** If the user scaffolded the repo with +`apiops init`, the generated publish pipeline already wires up a standard set +of pipeline variables / secrets that are usable as `{#[...]#}` tokens. You may +**ask** whether the user has these (substitute `` with the uppercased +environment name, e.g. `STAGE`): + +- `AZURE_SUBSCRIPTION_ID_` (e.g., `AZURE_SUBSCRIPTION_ID_STAGE`) +- `APIM_RESOURCE_GROUP_` (e.g., `APIM_RESOURCE_GROUP_STAGE`) +- `APIM_SERVICE_NAME_` (e.g., `APIM_SERVICE_NAME_STAGE`) +- GitHub Actions only: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID` (used for login; + rarely needed inside override files) + +> Note: some repos use `AZURE_SUBSCRIPTION_ID` (global, no env suffix) as the +> default init-generated name. Others customize to `AZURE_SUBSCRIPTION_ID_`. +> Ask the user which naming pattern exists in their pipeline. + +Ask the user to **confirm which of these they actually have**, then ask whether +there are **any other** tokens: + +> "If you ran `apiops init`, you may already have these pipeline variables: +> `AZURE_SUBSCRIPTION_ID` (or `AZURE_SUBSCRIPTION_ID_STAGE`), +> `APIM_RESOURCE_GROUP_STAGE`, `APIM_SERVICE_NAME_STAGE`. +> Which of these do you have? And are there any other pipeline variables (with +> their exact, case-sensitive names) I should use as tokens?" + +> **Beyond the known `init` tokens, do NOT propose, guess, or pre-populate +> tokens.** Do not invent token names for secrets, URLs, or resource segments. +> Let the user tell you what else exists. + +Record this list of confirmed tokens. You may **only** use these token names +later. Never invent a token, and never wrap a value in `{#[TOKEN_NAME]#}` +unless its token is on this confirmed list. The publish step fails if a token +has no matching pipeline variable. + +**STOP and confirm the token list before continuing to Step 4.** + +--- + +## Step 4 — Fill In Each Override With the User + +Walk through the stub entries created in Step 2 **one setting/property at a +time** (for example one `resourceId` or one `value` field), not one whole +override object at a time. Do not batch multiple settings in a single prompt. + +**Single-setting cadence for Step 4:** + +- Ask for exactly one setting when information is missing. +- If the user provides that setting unambiguously, write it immediately. +- After writing it, proceed by asking for the next single missing setting. +- Only pause for confirmation when the user explicitly asks for confirmation, + or when the value is ambiguous and you need clarification. +- Do not ask the user to reconfirm a setting they just provided unless there is + a concrete ambiguity. + +For each override value, classify how it should be supplied using the confirmed +token list from Step 3. There are three kinds of values — do not default to +tokens: + +| Kind | When to use | How it's written | +| --- | --- | --- | +| **Literal value** | Non-sensitive settings that are safe to commit — API/backend URLs, resource IDs, Application Insights logger resource IDs, **Application Insights instrumentation keys** (telemetry ingestion keys, **not secrets**), feature flags. | Plain YAML value, e.g. `url: "https://api.contoso.com"` | +| **Pipeline token** | Secrets or values the user wants injected at publish time from the pipeline's secret store (GitHub Actions secrets / Azure DevOps variable groups). | `value: "{#[TOKEN_NAME]#}"` — only use a token the user confirms exists | +| **Key Vault reference** | Secrets stored centrally in Azure Key Vault and referenced by named values. | A `keyVault.secretIdentifier` URL (see pattern below) | + +For each candidate value, ask the user something like: + +> "For `.`, what is the value in ****? Is it a fixed +> value I can write directly, a secret your pipeline injects via a token, or a +> Key Vault secret?" + +Concrete guidance to follow while classifying: + +- **API service URLs and backend URLs** — Ask the user for the actual URL per + environment. These are usually plain literal values, **not tokens**, unless + the user specifically wants them injected by the pipeline. +- **Application Insights instrumentation keys** — These are **not secrets**. + Write the value the user provides directly, or leave the extracted value in + place. Do **not** wrap them in `{#[TOKEN_NAME]#}` unless the user asks. +- **Resource IDs (loggers, backends, diagnostics)** — Usually literal values. + Only the subscription ID / resource group / service name segments need + tokenizing if the user wants them injected; ask first. +- **Connection strings, API keys, passwords** — These are secrets. Use a + pipeline token or a Key Vault reference based on the user's preference. + +### Key Vault reference — the correct pattern + +A Key Vault-backed named value uses a **`keyVault.secretIdentifier`** that is a +**full secret URL**. Do **not** create a separate named value just to hold a +Key Vault base URL, and do **not** concatenate a token with a secret name. + +Correct — literal full secret identifier: + +```yaml +namedValues: + - name: db-connection-string + properties: + keyVault: + secretIdentifier: "https://prod-kv.vault.azure.net/secrets/db-conn" + identityClientId: "{#[MANAGED_IDENTITY_CLIENT_ID]#}" +``` + +Also acceptable — tokenize the whole secret identifier when the user wants the +pipeline to supply it: + +```yaml +namedValues: + - name: db-connection-string + properties: + keyVault: + secretIdentifier: "{#[DB_CONN_SECRET_IDENTIFIER]#}" + identityClientId: "{#[MANAGED_IDENTITY_CLIENT_ID]#}" +``` + +As you fill in each override, write it into the stub file using the right form: + +- Write literal values directly; use `{#[TOKEN_NAME]#}` only for confirmed + tokens; use full `keyVault.secretIdentifier` URLs for Key Vault secrets. +- Never commit real secret values — those must be tokens or Key Vault + references. + +Continue setting-by-setting until there are no missing values. + +--- + +## Step 5 — Finalize and Review the Override Files + +Once every stub override has been filled in across all environments: + +- Re-read each `configuration.{env}.yaml` file and confirm it is valid YAML + with no leftover blank values from the stubs. +- Confirm the schema comment is present as the first line of each file. +- Keep files easy to compare across environments and avoid duplicating + unchanged base configuration. + +Show the finalized files and **STOP for confirmation** before treating them as +final. + +--- + +## Step 6 — Validate the Promotion Model + +Before finishing: + +1. Verify every generated override file matches the intended environment. +2. Verify all **secrets** use either `{#[TOKEN_NAME]#}` or a Key Vault + reference — and that non-secrets (URLs, resource IDs, instrumentation keys) + are written as plain values, not tokens. +3. Confirm every `{#[TOKEN_NAME]#}` used corresponds to a token the user said + exists in their pipeline. +4. Remind the user to add any `{#[TOKEN_NAME]#}` tokens to their pipeline's + secret store (GitHub Actions Secrets or Azure DevOps variable groups). + Help with this if they ask. Note that the pipeline fails with an error if + any tokens are missing. +5. Remind the user to test publish for a lower environment before promoting + further. diff --git a/src/templates/copilot/configure-overrides-prompt.ts b/src/templates/copilot/configure-overrides-prompt.ts new file mode 100644 index 00000000..9acf3d9a --- /dev/null +++ b/src/templates/copilot/configure-overrides-prompt.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * GitHub Copilot prompt template for configuring environment overrides. + * Generates a .prompt.md file that guides Copilot through creating + * configuration.{env}.yaml override files for environment promotion. + */ + +import { copilotConfigureOverridesPromptTemplate } from '../generated/embedded-markdown.js'; + +export function generateConfigureOverridesPrompt(): string { + return copilotConfigureOverridesPromptTemplate; +} diff --git a/src/templates/copilot/identity-setup-prompt.ts b/src/templates/copilot/identity-setup-prompt.ts index 94a31644..41734811 100644 --- a/src/templates/copilot/identity-setup-prompt.ts +++ b/src/templates/copilot/identity-setup-prompt.ts @@ -16,19 +16,13 @@ import { copilotGithubEnvironmentSecretCommandsTemplate, copilotGitHubActionsIdentitySetupPromptTemplate, } from '../generated/embedded-markdown.js'; +import { renderTemplate } from '../../lib/render-template.js'; export interface IdentitySetupPromptConfig { environments: string[]; ciProvider?: 'github-actions' | 'azure-devops'; } -function renderTemplate(template: string, tokens: Record): string { - return Object.entries(tokens).reduce( - (rendered, [key, value]) => rendered.replaceAll(`{{${key}}}`, value), - template - ); -} - export function generateIdentitySetupPrompt(config: IdentitySetupPromptConfig): string { if (config.ciProvider === 'azure-devops') { const environmentsArrayPowerShell = config.environments diff --git a/tests/integration/package-build/package-build.test.ts b/tests/integration/package-build/package-build.test.ts index 06071e19..59039312 100644 --- a/tests/integration/package-build/package-build.test.ts +++ b/tests/integration/package-build/package-build.test.ts @@ -45,14 +45,14 @@ async function runNpm(args: string[]): Promise { return result.stdout; } -async function collectMarkdownFiles(dirPath: string): Promise { +async function collectEmbeddableTemplateFiles(dirPath: string): Promise { const entries = await fs.readdir(dirPath, { withFileTypes: true }); const nested = await Promise.all(entries.map(async (entry) => { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { - return collectMarkdownFiles(fullPath); + return collectEmbeddableTemplateFiles(fullPath); } - return entry.name.endsWith('.md') ? [fullPath] : []; + return entry.name.endsWith('.md') || entry.name.endsWith('.yaml') ? [fullPath] : []; })); return nested.flat(); @@ -94,14 +94,14 @@ describe('package build integration', () => { expect(distFiles.length).toBeGreaterThan(0); }, 240_000); - it('should include all src/templates markdown files in packed output via embedded template constants', async () => { + it('should include all src/templates markdown and yaml files in packed output via embedded template constants', async () => { await runNpm(['run', 'build']); const templateRoot = path.join(repoRoot, 'src/templates'); - const markdownFiles = await collectMarkdownFiles(templateRoot); - expect(markdownFiles.length).toBeGreaterThan(0); + const templateFiles = await collectEmbeddableTemplateFiles(templateRoot); + expect(templateFiles.length).toBeGreaterThan(0); - const expectedTemplateContents = await Promise.all(markdownFiles.map(async (filePath) => { + const expectedTemplateContents = await Promise.all(templateFiles.map(async (filePath) => { const relPath = normalizePath(path.relative(templateRoot, filePath)); const content = await fs.readFile(filePath, 'utf8'); return { relPath, content }; diff --git a/tests/unit/services/init-service.test.ts b/tests/unit/services/init-service.test.ts index eb4c8849..d9b3f89b 100644 --- a/tests/unit/services/init-service.test.ts +++ b/tests/unit/services/init-service.test.ts @@ -357,6 +357,35 @@ describe('init-service', () => { expect(content).toContain('gh secret set'); }); + it('should generate Copilot configuration prompts for GitHub Actions', async () => { + const config: InitConfig = { + ciProvider: 'github-actions', + nonInteractive: true, + artifactDir: './apim-artifacts', + environments: ['dev', 'prod'], + outputDir: '/test', + cliPackage: TEST_CLI_PACKAGE, + force: false, + }; + + const result = await initService.run(config); + + expect(result.configs).toContain('.github/prompts/apiops-configure-filter.prompt.md'); + expect(result.configs).toContain('.github/prompts/apiops-configure-overrides.prompt.md'); + + const filterPromptCalls = vi.mocked(fs.writeFile).mock.calls.filter( + (call) => call[0] === path.join('/test', '.github/prompts/apiops-configure-filter.prompt.md') + ); + expect(filterPromptCalls).toHaveLength(1); + expect(filterPromptCalls[0][1]).toContain('Configure APIOps Extractor Filters'); + + const overridesPromptCalls = vi.mocked(fs.writeFile).mock.calls.filter( + (call) => call[0] === path.join('/test', '.github/prompts/apiops-configure-overrides.prompt.md') + ); + expect(overridesPromptCalls).toHaveLength(1); + expect(overridesPromptCalls[0][1]).toContain('Configure APIOps Environment Overrides'); + }); + it('should generate Copilot identity setup prompt for Azure DevOps', async () => { const config: InitConfig = { ciProvider: 'azure-devops', @@ -380,6 +409,34 @@ describe('init-service', () => { expect(content).toContain('az devops service-endpoint create --service-endpoint-configuration'); }); + it('should detect conflicts for Copilot configuration prompts', async () => { + vi.mocked(fs.access).mockImplementation(async (filePath: PathLike) => { + const p = filePath.toString(); + if ( + p === TEST_CLI_PACKAGE_RESOLVED || + p.includes('apiops-configure-filter.prompt.md') || + p.includes('apiops-configure-overrides.prompt.md') + ) { + return Promise.resolve(); + } + throw new Error('ENOENT'); + }); + + const config: InitConfig = { + ciProvider: 'azure-devops', + nonInteractive: true, + artifactDir: './apim-artifacts', + environments: ['dev'], + outputDir: '/test', + cliPackage: TEST_CLI_PACKAGE, + force: false, + }; + + await expect(initService.run(config)).rejects.toThrow( + 'Use --force to overwrite existing files' + ); + }); + it('should copy CLI tarball into .apiops directory', async () => { const config: InitConfig = { ciProvider: 'github-actions', @@ -602,5 +659,6 @@ describe('init-service', () => { expect(pkg.dependencies.lodash).toBe('^4.17.21'); expect(pkg.dependencies['@peterhauge/apiops-cli']).toBe('latest'); }); + }); }); diff --git a/tests/unit/templates/configs/config-templates.test.ts b/tests/unit/templates/configs/config-templates.test.ts index 69a0e3f1..7d85014d 100644 --- a/tests/unit/templates/configs/config-templates.test.ts +++ b/tests/unit/templates/configs/config-templates.test.ts @@ -69,6 +69,17 @@ describe('configs/filter-config', () => { const lines = config.split('\n').filter((line) => line.trim() && !line.trim().startsWith('#')); expect(lines).toHaveLength(0); }); + + it('should render the extractor schema URL in the yaml-language-server header', () => { + const config = generateFilterConfig(); + expect(config).toContain('# yaml-language-server: $schema='); + expect(config).toContain('schemas/v1/extractor-config.schema.json'); + }); + + it('should not leave any unrendered template placeholders', () => { + const config = generateFilterConfig(); + expect(config).not.toMatch(/\{\{[^}]+\}\}/); + }); }); }); @@ -140,5 +151,17 @@ describe('configs/override-config', () => { const lines = config.split('\n').filter((line) => line.trim() && !line.trim().startsWith('#')); expect(lines).toHaveLength(0); }); + + it('should render the override schema URL in the yaml-language-server header', () => { + const config = generateOverrideConfig('dev'); + expect(config).toContain('# yaml-language-server: $schema='); + expect(config).toContain('schemas/v1/override-config.schema.json'); + }); + + it('should substitute every environment placeholder', () => { + const config = generateOverrideConfig('staging'); + expect(config).not.toMatch(/\{\{[^}]+\}\}/); + expect(config).toContain('staging-api-key-value'); + }); }); }); diff --git a/tests/unit/templates/copilot/configure-filter-prompt.test.ts b/tests/unit/templates/copilot/configure-filter-prompt.test.ts new file mode 100644 index 00000000..398b2012 --- /dev/null +++ b/tests/unit/templates/copilot/configure-filter-prompt.test.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Unit tests for GitHub Copilot filter configuration prompt template + */ + +import { describe, it, expect } from 'vitest'; +import { generateConfigureFilterPrompt } from '../../../../src/templates/copilot/configure-filter-prompt.js'; + +describe('copilot/configure-filter-prompt', () => { + it('should return the static filter prompt template', () => { + const prompt = generateConfigureFilterPrompt({ environments: ['dev', 'prod'] }); + + expect(prompt).toContain('# Configure APIOps Extractor Filters'); + expect(prompt).toContain('configuration.extractor.yaml'); + expect(prompt).toContain('## Step 1'); + expect(prompt).toContain('## Step 4'); + }); +}); diff --git a/tests/unit/templates/copilot/configure-overrides-prompt.test.ts b/tests/unit/templates/copilot/configure-overrides-prompt.test.ts new file mode 100644 index 00000000..713ea6ae --- /dev/null +++ b/tests/unit/templates/copilot/configure-overrides-prompt.test.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Unit tests for GitHub Copilot environment override prompt template + */ + +import { describe, it, expect } from 'vitest'; +import { generateConfigureOverridesPrompt } from '../../../../src/templates/copilot/configure-overrides-prompt.js'; + +describe('copilot/configure-overrides-prompt', () => { + it('should produce a static prompt with environment auto-detection instructions', () => { + const prompt = generateConfigureOverridesPrompt(); + + expect(prompt).toContain('# Configure APIOps Environment Overrides'); + expect(prompt).toContain('configuration.{environment}.yaml'); + expect(prompt).toContain('Detect and Confirm Environments'); + expect(prompt).toContain('configuration.*.yaml'); + expect(prompt).not.toMatch(/\{\{[^}]+\}\}/); + }); +});