diff --git a/docs/contributor-guide/architecture/adapters.md b/docs/contributor-guide/architecture/adapters.md index 444f9a40..a730c246 100644 --- a/docs/contributor-guide/architecture/adapters.md +++ b/docs/contributor-guide/architecture/adapters.md @@ -29,11 +29,13 @@ interface IRepositoryAdapter { | **LocalAwesomeCopilotAdapter** | `local-awesome-copilot` | Buffer-based | Active | | **ApmAdapter** | `apm` | URL-based | Active | | **LocalApmAdapter** | `local-apm` | Buffer-based | Active | +| **AzureDevOpsAdapter** | `azure-devops` | Buffer-based (ADO Items API zip) | Active | Source types are defined in `src/types/registry.ts`: ```typescript export type SourceType = 'github' | 'local' | - 'awesome-copilot' | 'local-awesome-copilot' | 'apm' | 'local-apm'; + 'awesome-copilot' | 'local-awesome-copilot' | 'apm' | 'local-apm' | + 'azure-devops'; ``` ## Two Installation Paths diff --git a/docs/reference/adapter-api.md b/docs/reference/adapter-api.md index b27e6518..3c042ea8 100644 --- a/docs/reference/adapter-api.md +++ b/docs/reference/adapter-api.md @@ -170,6 +170,46 @@ export type SourceType = | `LocalApmAdapter` | `local-apm` | Local APM packages | Active | | `SkillsAdapter` | `skills` | Fetches skills from a GitHub repository's `skills/` directory | Active | | `LocalSkillsAdapter` | `local-skills` | Local filesystem skills directory | Active | +| `AzureDevOpsAdapter` | `azure-devops` | Private ADO Git repositories (cloud or on-premises) | Active | + +### AzureDevOpsAdapter + +Fetches bundles from Azure DevOps Git repositories by scanning for `.collection.yml` files +under `collectionsPath`. Two repo layouts are supported: + +- **Flat layout (depth-0)**: the `.collection.yml` lives directly inside `collectionsPath` + (e.g. `collections/my-collection.collection.yml`). Item paths in the YAML are relative to + the repo root. Skill items reference the skill directory path. +- **Bundle-directory layout (depth-1)**: each `.collection.yml` lives inside its own + subdirectory one level beneath `collectionsPath` (e.g. `collections/my-bundle/my-bundle.collection.yml`). + +Bundles are assembled on-the-fly: each listed item is +fetched individually and packaged with a synthesised `deployment-manifest.yml` into an +in-memory ZIP archive — no `deployment-manifest.yml` needs to exist in the repository. + +**Configuration:** + +```json +{ + "id": "my-ado-source", + "name": "My ADO Prompts", + "type": "azure-devops", + "url": "https://dev.azure.com/myorg/myproject/_git/myrepo", + "enabled": true, + "priority": 1, + "private": true, + "token": "", + "config": { + "branch": "main", + "collectionsPath": "/" + } +} +``` + +**Authentication:** Uses `AzureDevOpsAuthService` with the following fallback chain: +1. PAT from `source.token` (Basic auth) +2. VS Code Microsoft authentication (Bearer token via `vscode.authentication.getSession`) +3. Azure CLI: `az account get-access-token` (Bearer token) ## Authentication diff --git a/package-lock.json b/package-lock.json index 805239f1..2efb1cc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2626,31 +2626,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/package-json/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@npmcli/package-json/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@npmcli/package-json/node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -3076,29 +3051,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@o3r/eslint-plugin/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@o3r/eslint-plugin/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@o3r/eslint-plugin/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -3293,29 +3245,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@o3r/schematics/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@o3r/schematics/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@o3r/schematics/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3817,31 +3746,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@tufjs/models/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@tufjs/models/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@tufjs/models/node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -4715,29 +4619,6 @@ "win32" ] }, - "node_modules/@vscode/vsce/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@vscode/vsce/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@vscode/vsce/node_modules/glob": { "version": "7.2.3", "dev": true, @@ -5665,13 +5546,24 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -5855,31 +5747,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/cacache/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/cacache/node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -8743,25 +8610,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.3", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/glob/node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -9160,31 +9008,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/ignore-walk/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/ignore-walk/node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -9959,29 +9782,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-processinfo/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/istanbul-lib-processinfo/node_modules/glob": { "version": "7.2.3", "dev": true, @@ -10996,29 +10796,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimatch/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/minimist": { "version": "1.2.8", "dev": true, @@ -11731,29 +11508,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/nyc/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/nyc/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/nyc/node_modules/cliui": { "version": "6.0.0", "dev": true, @@ -11854,6 +11608,29 @@ "node": "*" } }, + "node_modules/nyc/node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nyc/node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/nyc/node_modules/p-limit": { "version": "2.3.0", "dev": true, @@ -13175,27 +12952,6 @@ "url": "https://github.com/sponsors/yqnn" } }, - "node_modules/readdir-glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/readdir-glob/node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -13467,27 +13223,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/rimraf/node_modules/glob": { "version": "13.0.5", "dev": true, @@ -14289,29 +14024,6 @@ "node": ">=8" } }, - "node_modules/spawn-wrap/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/spawn-wrap/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/spawn-wrap/node_modules/foreground-child": { "version": "2.0.0", "dev": true, diff --git a/src/adapters/azure-devops-adapter.ts b/src/adapters/azure-devops-adapter.ts new file mode 100644 index 00000000..bd680204 --- /dev/null +++ b/src/adapters/azure-devops-adapter.ts @@ -0,0 +1,1123 @@ +/** + * Azure DevOps Repository Adapter + * + * Fetches prompt bundles from Azure DevOps (ADO) Git repositories. + * Supports both Azure DevOps Services (cloud) and Azure DevOps Server (on-premises). + * + * ## Bundle discovery strategy — "full-tree collection scan" + * + * Rather than listing directories one-by-one and probing each for a manifest + * (an N+1 HTTP pattern), the adapter retrieves the **entire repository tree + * in a single API call** using `recursionLevel=Full`, then filters the + * returned item list in memory for blob entries (files) whose filename ends in + * `.collection.yml`. + * + * Only collection files that sit **exactly one directory level** beneath + * `collectionsPath` are treated as bundle roots. This prevents deeply-nested + * files from accidentally being picked up as separate bundles. + * + * After finding collection blob paths, the adapter fetches the **content** of + * each `.collection.yml` file (one request per bundle) and parses it to + * construct `Bundle` objects. + * + * **API call count**: 1 (full tree) + N (one per discovered bundle) + * + * ## Downloading bundles + * Bundles are assembled on the fly: the adapter re-fetches the `.collection.yml`, + * individually downloads each item listed there, and packages them — together + * with a synthesised `deployment-manifest.yml` — into an in-memory ZIP archive. + * No `deployment-manifest.yml` needs to exist in the repository. + * + * ## Configuration example + * + * ### Azure DevOps Services (cloud) + * ```json + * { + * "id": "my-ado-source", + * "name": "My ADO Prompts", + * "type": "azure-devops", + * "url": "https://dev.azure.com/myorg/myproject/_git/myrepo", + * "enabled": true, + * "priority": 1, + * "private": true, + * "token": "", + * "config": { + * "branch": "main", + * "collectionsPath": "/" + * } + * } + * ``` + * + * ### Azure DevOps Server (on-premises) + * ```json + * { + * "id": "my-ado-server-source", + * "name": "My ADO Server Prompts", + * "type": "azure-devops", + * "url": "https://ado.mycompany.com/DefaultCollection/myproject/_git/myrepo", + * "enabled": true, + * "priority": 1, + * "private": true, + * "token": "", + * "config": { + * "branch": "main", + * "collectionsPath": "/prompt-bundles" + * } + * } + * ``` + * + * ## Authentication + * Configure authentication in priority order — the first option that succeeds is used: + * + * 1. **Personal Access Token (PAT)**: Set `token` on the source. Generate a PAT with + * "Code (read)" scope at https://dev.azure.com/{org}/_usersettings/tokens. + * Produces an `Authorization: Basic base64(":"+PAT)` header. + * + * 2. **VS Code Microsoft auth**: Sign in to VS Code with your Microsoft/Entra account. + * No CLI tooling needed. The adapter calls `vscode.authentication.getSession('microsoft', ...)` + * silently and produces an `Authorization: Bearer ` header. + * + * 3. **Azure CLI**: Run `az login` before using the extension. The adapter calls + * `az account get-access-token` automatically and produces a Bearer header. + */ + +import * as https from 'node:https'; +import archiver from 'archiver'; +import * as yaml from 'js-yaml'; +import { + AzureDevOpsAuthMethod, + AzureDevOpsAuthService, +} from '../services/azure-devops-auth'; +import { + Bundle, + RegistrySource, + SourceMetadata, + ValidationResult, +} from '../types/registry'; +import { + Logger, +} from '../utils/logger'; +import { + RepositoryAdapter, +} from './repository-adapter'; + +/** Maximum redirect depth to prevent infinite loops */ +const MAX_REDIRECTS = 10; + +/** ADO REST API version used for all requests */ +const ADO_API_VERSION = '7.0'; + +// --------------------------------------------------------------------------- +// Collection manifest schema (shared with awesome-copilot-adapter) +// --------------------------------------------------------------------------- + +/** + * Schema for a `.collection.yml` file — a lightweight manifest that lists + * the individual prompt/instruction/chat-mode files that make up a bundle. + * The adapter reads this file from the ADO repository and synthesises a + * standard `deployment-manifest.yml` on the fly during download. + */ +interface CollectionManifest { + id: string; + name: string; + description: string; + version?: string; + author?: string; + tags?: string[]; + items: CollectionItem[]; +} + +interface CollectionItem { + path: string; + kind: 'prompt' | 'instruction' | 'chat-mode' | 'agent' | 'skill'; +} + +/** + * ADO Items API response for a single item + */ +interface AdoItem { + objectId: string; + gitObjectType: 'blob' | 'tree' | 'commit' | 'tag'; + commitId: string; + path: string; + isFolder: boolean; + url: string; +} + +/** + * ADO Items API list response + */ +interface AdoItemsResponse { + count: number; + value: AdoItem[]; +} + +/** + * ADO Repository metadata response + */ +interface AdoRepository { + id: string; + name: string; + project: { + name: string; + description?: string; + }; + remoteUrl: string; + defaultBranch?: string; +} + +/** + * Parsed components extracted from an Azure DevOps repository URL. + * + * Supported URL formats: + * - `https://dev.azure.com/{org}/{project}/_git/{repo}` + * - `https://{org}.visualstudio.com/{project}/_git/{repo}` + * - `https://{server}/{collection}/{project}/_git/{repo}` (on-premises) + */ +interface ParsedAdoUrl { + /** Full base URL up to and including the project segment */ + projectBaseUrl: string; + /** Repository name */ + repository: string; +} + +/** + * Azure DevOps repository adapter. + * + * Implements `IRepositoryAdapter` to expose prompt bundles stored in an ADO Git + * repository. Bundles are discovered by scanning for `deployment-manifest.yml` + * files under the configured `collectionsPath`. + */ +export class AzureDevOpsAdapter extends RepositoryAdapter { + public readonly type = 'azure-devops'; + + private readonly logger: Logger; + private readonly authService: AzureDevOpsAuthService; + + /** Cached resolved auth token — set after the first successful authentication */ + private authToken: string | undefined; + /** How the cached token was obtained */ + private authMethod: AzureDevOpsAuthMethod = 'none'; + + constructor(source: RegistrySource) { + super(source); + this.logger = Logger.getInstance(); + this.authService = new AzureDevOpsAuthService(); + + if (!this.isValidAdoUrl(source.url)) { + throw new Error( + `Invalid Azure DevOps URL: "${source.url}". ` + + 'Expected format: https://dev.azure.com/{org}/{project}/_git/{repo} ' + + 'or https://{org}.visualstudio.com/{project}/_git/{repo}' + ); + } + } + + // --------------------------------------------------------------------------- + // Private getters + // --------------------------------------------------------------------------- + + /** + * Branch to use when fetching from the repository. + * Defaults to `'main'` if not specified in source config. + */ + private get branch(): string { + return (this.source.config?.branch) ?? 'main'; + } + + /** + * Root path within the repository to scan for bundles. + * Defaults to `'/'` (repository root) if not specified in source config. + */ + private get collectionsPath(): string { + const p = (this.source.config?.collectionsPath) ?? '/'; + return p.startsWith('/') ? p : `/${p}`; + } + + // --------------------------------------------------------------------------- + // URL parsing + // --------------------------------------------------------------------------- + + /** + * Validate that the given URL looks like an Azure DevOps repository URL. + * + * Accepted patterns: + * - `https://dev.azure.com/.../_git/...` + * - `https://*.visualstudio.com/.../_git/...` + * - Any other HTTPS URL containing `/_git/` (covers on-premises deployments) + * + * Only HTTPS URLs are accepted to ensure credentials are never sent in plain text. + * @param urlString - URL to validate + */ + private isValidAdoUrl(urlString: string): boolean { + if (!urlString.startsWith('https://')) { + return false; + } + return urlString.includes('/_git/'); + } + + /** + * Parse the Azure DevOps repository URL into components used for API calls. + * + * For `https://dev.azure.com/org/project/_git/repo`: + * - projectBaseUrl = `https://dev.azure.com/org/project` + * - repository = `repo` + * + * For `https://org.visualstudio.com/project/_git/repo`: + * - projectBaseUrl = `https://org.visualstudio.com/project` + * - repository = `repo` + * + * For on-premises `https://server/collection/project/_git/repo`: + * - projectBaseUrl = `https://server/collection/project` + * - repository = `repo` + */ + private parseAdoUrl(): ParsedAdoUrl { + const gitIdx = this.source.url.indexOf('/_git/'); + if (gitIdx === -1) { + throw new Error(`Cannot parse Azure DevOps URL: "${this.source.url}"`); + } + + const projectBaseUrl = this.source.url.substring(0, gitIdx); + const afterGit = this.source.url.substring(gitIdx + '/_git/'.length); + const repository = afterGit.split(/[?#/]/)[0]; + + if (!repository) { + throw new Error(`Cannot extract repository name from URL: "${this.source.url}"`); + } + + return { projectBaseUrl, repository }; + } + + /** + * Build the ADO Git API base URL for this repository. + * Example: `https://dev.azure.com/org/project/_apis/git/repositories/repo` + */ + private buildApiBase(): string { + const { projectBaseUrl, repository } = this.parseAdoUrl(); + return `${projectBaseUrl}/_apis/git/repositories/${encodeURIComponent(repository)}`; + } + + // --------------------------------------------------------------------------- + // Authentication + // --------------------------------------------------------------------------- + + /** + * Resolve and cache the authentication token. + * Uses `source.token` (PAT) first, then Azure CLI fallback. + */ + private async getAuthenticationToken(): Promise<{ token: string; method: AzureDevOpsAuthMethod } | undefined> { + if (this.authToken !== undefined) { + this.logger.debug(`[AzureDevOpsAdapter] Using cached token (method: ${this.authMethod})`); + return { token: this.authToken, method: this.authMethod }; + } + + const result = await this.authService.getToken(this.getAuthToken()); + if (result) { + this.authToken = result.token; + this.authMethod = result.method; + } + return result ?? undefined; + } + + // --------------------------------------------------------------------------- + // HTTP helpers + // --------------------------------------------------------------------------- + + /** + * Build request headers including the Authorization header when a token is available. + * @param accept - Accept header value + */ + private async buildHeaders(accept: string): Promise> { + const headers: Record = { + 'User-Agent': 'Prompt-Registry-VSCode-Extension/1.0', + Accept: accept + }; + + const auth = await this.getAuthenticationToken(); + if (auth) { + headers.Authorization = this.authService.buildAuthHeader(auth.token, auth.method); + this.logger.debug(`[AzureDevOpsAdapter] Auth header set (method: ${auth.method})`); + } else { + this.logger.debug('[AzureDevOpsAdapter] No auth header — unauthenticated request'); + } + + return headers; + } + + /** + * Construct a user-friendly HTTP error message. + * @param statusCode - HTTP status code + * @param requestUrl - URL that returned the error + */ + private buildHttpErrorMessage(statusCode: number, requestUrl: string): string { + switch (statusCode) { + case 401: { + return `Azure DevOps authentication failed (HTTP 401) for ${requestUrl}. ` + + 'Check that your PAT has "Code (read)" scope, sign in to VS Code with your ' + + 'Microsoft account, or run `az login`.'; + } + case 403: { + return `Azure DevOps access denied (HTTP 403) for ${requestUrl}. ` + + 'Your token may lack the required permissions.'; + } + case 404: { + return `Azure DevOps resource not found (HTTP 404) for ${requestUrl}. ` + + 'Verify the organization, project, repository name, and branch.'; + } + default: { + return `Azure DevOps API error (HTTP ${statusCode}) for ${requestUrl}.`; + } + } + } + + /** + * Make an HTTPS GET request and return the response body as a string. + * Follows redirects up to `MAX_REDIRECTS` levels deep. + * @param requestUrl - Full URL to request + * @param accept - Accept header value + * @param depth - Current redirect depth (used internally) + */ + private async fetchString(requestUrl: string, accept = 'application/json', depth = 0): Promise { + if (depth >= MAX_REDIRECTS) { + throw new Error(`[AzureDevOpsAdapter] Maximum redirect depth (${MAX_REDIRECTS}) exceeded for ${requestUrl}`); + } + + const headers = await this.buildHeaders(accept); + const parsedUrl = new URL(requestUrl); + + const sanitized = { ...headers }; + if (sanitized.Authorization) { + // Log only the auth scheme (e.g. "Basic" or "Bearer") to avoid token exposure + sanitized.Authorization = sanitized.Authorization.split(' ')[0] + ' [redacted]'; + } + this.logger.debug(`[AzureDevOpsAdapter] GET ${requestUrl} headers: ${JSON.stringify(sanitized)}`); + + return new Promise((resolve, reject) => { + const options: https.RequestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: parsedUrl.pathname + parsedUrl.search, + method: 'GET', + headers + }; + + const req = https.request(options, (res) => { + if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) { + const redirectUrl = res.headers.location.startsWith('http') + ? res.headers.location + : new URL(res.headers.location, requestUrl).toString(); + this.logger.debug(`[AzureDevOpsAdapter] Redirect (${depth + 1}) → ${redirectUrl}`); + this.fetchString(redirectUrl, accept, depth + 1).then(resolve).catch(reject); + return; + } + + let data = ''; + res.on('data', (chunk: string) => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 400) { + this.logger.error(`[AzureDevOpsAdapter] HTTP ${res.statusCode} for ${requestUrl}: ${data.substring(0, 300)}`); + reject(new Error(this.buildHttpErrorMessage(res.statusCode, requestUrl))); + return; + } + resolve(data); + }); + }); + + req.on('error', (err) => { + this.logger.error(`[AzureDevOpsAdapter] Network error for ${requestUrl}: ${err.message}`); + reject(new Error(`Azure DevOps request failed: ${err.message}`)); + }); + + req.end(); + }); + } + + /** + * Make an HTTPS GET request and return the response body as a Buffer. + * Used for binary downloads (ZIP archives). + * Follows redirects up to `MAX_REDIRECTS` levels deep. + * @param requestUrl - Full URL to request + * @param depth - Current redirect depth (used internally) + */ + private async fetchBuffer(requestUrl: string, depth = 0): Promise { + if (depth >= MAX_REDIRECTS) { + throw new Error(`[AzureDevOpsAdapter] Maximum redirect depth (${MAX_REDIRECTS}) exceeded for ${requestUrl}`); + } + + const headers = await this.buildHeaders('application/zip'); + const parsedUrl = new URL(requestUrl); + + this.logger.debug(`[AzureDevOpsAdapter] GET (binary) ${requestUrl}`); + + return new Promise((resolve, reject) => { + const options: https.RequestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: parsedUrl.pathname + parsedUrl.search, + method: 'GET', + headers + }; + + const req = https.request(options, (res) => { + if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) { + const redirectUrl = res.headers.location.startsWith('http') + ? res.headers.location + : new URL(res.headers.location, requestUrl).toString(); + this.logger.debug(`[AzureDevOpsAdapter] Redirect (binary, ${depth + 1}) → ${redirectUrl}`); + this.fetchBuffer(redirectUrl, depth + 1).then(resolve).catch(reject); + return; + } + + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 400) { + const body = Buffer.concat(chunks).toString('utf8').substring(0, 300); + this.logger.error(`[AzureDevOpsAdapter] HTTP ${res.statusCode} for ${requestUrl}: ${body}`); + reject(new Error(this.buildHttpErrorMessage(res.statusCode, requestUrl))); + return; + } + resolve(Buffer.concat(chunks)); + }); + }); + + req.on('error', (err) => { + this.logger.error(`[AzureDevOpsAdapter] Network error (binary) for ${requestUrl}: ${err.message}`); + reject(new Error(`Azure DevOps download failed: ${err.message}`)); + }); + + req.end(); + }); + } + + // --------------------------------------------------------------------------- + // ADO API helpers + // --------------------------------------------------------------------------- + + /** + * Encode a repository path for use as a query-string parameter value. + * + * `URLSearchParams` percent-encodes forward slashes (`/` → `%2F`), but the + * Azure DevOps Items API returns HTTP 400 when it receives `path=%2Fskills` + * instead of `path=/skills`. This helper percent-encodes each path segment + * individually so that slashes remain literal in the query string. + * @param path - Repository path, e.g. `/my-bundle/deployment-manifest.yml` + */ + private encodePath(path: string): string { + return path.split('/').map((segment) => encodeURIComponent(segment)).join('/'); + } + + /** + * Fetch **all** Git items in the repository in a **single API call** using + * `recursionLevel=Full`. + * + * The ADO Items API returns a flat list of every blob (file) and tree + * (directory) in the repository, together with their `path`, `isFolder`, + * and `gitObjectType` fields. Retrieving the full tree in one request is + * the foundation of the efficient blob-scan discovery strategy. + * + * The `path` query parameter is intentionally **omitted** from this call. + * Passing `path=` with `recursionLevel=Full` is unreliable across + * ADO versions and on-premises installations — some return HTTP 400 for any + * path value, others reject only `path=/` or percent-encoded slashes. + * Fetching the whole tree from root and filtering in memory via + * {@link findManifestBlobs} is simpler, correct on every ADO version, and + * has no correctness cost (prompt bundle repos are typically small). + * @returns Flat array of every item (blob or tree) in the repository + */ + private async fetchFullTree(): Promise { + const apiBase = this.buildApiBase(); + const params = new URLSearchParams({ + recursionLevel: 'Full', + 'versionDescriptor.version': this.branch, + 'versionDescriptor.versionType': 'branch', + 'api-version': ADO_API_VERSION + }); + const requestUrl = `${apiBase}/items?${params.toString()}`; + + this.logger.debug( + `[AzureDevOpsAdapter] Fetching full tree at "${this.collectionsPath}" ` + + `(branch: ${this.branch})` + ); + const responseText = await this.fetchString(requestUrl); + const response = JSON.parse(responseText) as AdoItemsResponse; + return response.value ?? []; + } + + /** + * Filter a flat item list for **collection blobs** that are at most one + * directory level beneath `collectionsPath`. + * + * Two layouts are supported: + * + * **Depth-0 (flat layout)** — the `.collection.yml` sits directly inside + * `collectionsPath`. Items at this depth have exactly one non-empty segment + * after the base prefix: + * ``` + * /collections/my-collection.collection.yml → depth 0 ✓ + * ``` + * + * **Depth-1 (bundle-directory layout)** — the `.collection.yml` sits one + * level deeper, inside a dedicated bundle subdirectory. Items at this depth + * have exactly two non-empty segments after the base prefix: + * ``` + * /collections/my-bundle/my-bundle.collection.yml → depth 1 ✓ + * ``` + * + * Files nested more than one level deep are silently ignored. + * + * Examples (collectionsPath = '/prompts'): + * ``` + * /prompts/my-collection.collection.yml → depth 0 ✓ + * /prompts/my-bundle/my-bundle.collection.yml → depth 1 ✓ + * /prompts/nested/inner/other.collection.yml → depth 2 ✗ (skipped) + * ``` + * @param items - All items returned by {@link fetchFullTree} + * @returns Items that are `.collection.yml` blobs at depth-0 or depth-1 under `collectionsPath` + */ + private findCollectionBlobs(items: AdoItem[]): AdoItem[] { + // Strip trailing slash from the base so the depth calculation is consistent + // for both '/' (which becomes '') and '/bundles' (which stays '/bundles'). + const base = this.collectionsPath.replace(/\/$/, ''); + + return items.filter((item) => { + // Only blobs (files) — skip trees (directories) + if (item.isFolder) { + return false; + } + + // File must be a .collection.yml file + const filename = item.path.split('/').pop() ?? ''; + if (!filename.endsWith('.collection.yml')) { + return false; + } + + // Compute the path relative to collectionsPath. We then split on '/' + // and count non-empty segments. + // segments.length === 1 → depth-0: directly in collectionsPath + // segments.length === 2 → depth-1: / + // length > 2 → nested too deep, skip + const relative = item.path.startsWith(base) + ? item.path.substring(base.length).replace(/^\//, '') + : item.path.replace(/^\//, ''); + + const segments = relative.split('/').filter(Boolean); + + return segments.length === 1 || segments.length === 2; + }); + } + + /** + * Fetch a single text file from the repository. + * @param path - Repository path of the file + */ + private async fetchFileContent(path: string): Promise { + const apiBase = this.buildApiBase(); + // Path must be appended outside URLSearchParams to keep '/' characters literal. + // URLSearchParams encodes '/' as '%2F', which the ADO Items API rejects (HTTP 400). + const params = new URLSearchParams({ + 'versionDescriptor.version': this.branch, + 'versionDescriptor.versionType': 'branch', + 'api-version': ADO_API_VERSION + }); + const requestUrl = `${apiBase}/items?${params.toString()}&path=${this.encodePath(path)}`; + + this.logger.debug(`[AzureDevOpsAdapter] Fetching file "${path}"`); + return this.fetchString(requestUrl, 'text/plain'); + } + + /** + * Download a directory as a ZIP archive from the ADO Items API. + * @param path - Repository path of the directory to zip + */ + private async downloadDirectoryAsZip(path: string): Promise { + const apiBase = this.buildApiBase(); + // Path must be appended outside URLSearchParams to keep '/' characters literal. + // URLSearchParams encodes '/' as '%2F', which the ADO Items API rejects (HTTP 400). + const params = new URLSearchParams({ + download: 'true', + recursionLevel: 'Full', + 'versionDescriptor.version': this.branch, + 'versionDescriptor.versionType': 'branch', + 'api-version': ADO_API_VERSION + }); + // `$format` uses a dollar sign which URLSearchParams encodes as %24. + // Appending it as a literal string is safe here since `$` is a valid + // query-string character (RFC 3986 §3.4) and ADO requires the exact string. + const requestUrl = `${apiBase}/items?${params.toString()}&path=${this.encodePath(path)}&$format=zip`; + + this.logger.debug(`[AzureDevOpsAdapter] Downloading directory "${path}" as ZIP`); + return this.fetchBuffer(requestUrl); + } + + // --------------------------------------------------------------------------- + // Bundle discovery helpers + // --------------------------------------------------------------------------- + + /** + * Parse the raw YAML text of a `.collection.yml` file. + * Returns `null` if the text cannot be parsed (malformed file). + * @param text - Raw YAML content of the collection file + * @param collectionPath - Repository path (used in log messages) + */ + private parseCollectionManifest(text: string, collectionPath: string): CollectionManifest | null { + try { + return yaml.load(text) as CollectionManifest; + } catch (err) { + this.logger.warn(`[AzureDevOpsAdapter] Failed to parse collection "${collectionPath}": ${err}`); + return null; + } + } + + /** + * Build a `Bundle` object from a parsed `CollectionManifest` and bundle directory path. + * + * **Depth-0 (flat layout)**: when `dirPath` equals the `collectionsPath` base the + * collection file lives directly inside `collectionsPath` (e.g. + * `/collections/my-collection.collection.yml`). In that case `dirPath` alone would + * be the same for every collection in the folder, so the manifest `id` field is + * appended to make each bundle ID unique and stable. + * + * **Depth-1 (bundle-directory layout)**: `dirPath` is the dedicated bundle + * subdirectory (e.g. `/collections/my-bundle`), which is already unique per bundle. + * @param collection - Parsed collection manifest + * @param dirPath - Repository path of the directory containing the `.collection.yml` + * @param dirName - Basename of that directory + * @param collectionPath - Full path to the `.collection.yml` file (used for manifestUrl) + */ + private buildBundleFromCollection( + collection: CollectionManifest, + dirPath: string, + dirName: string, + collectionPath: string + ): Bundle { + const bundleId = collection.id ?? dirName; + + return { + id: bundleId, + name: collection.name ?? dirName, + version: collection.version ?? '1.0.0', + description: collection.description ?? '', + author: collection.author ?? '', + sourceId: this.source.id, + environments: ['vscode'], + tags: collection.tags ?? [], + lastUpdated: new Date().toISOString(), + size: `${collection.items.length} items`, + dependencies: [], + license: 'Unknown', + manifestUrl: this.getCollectionFileUrl(collectionPath), + downloadUrl: this.getCollectionFileUrl(collectionPath), + repository: this.source.url + }; + } + + /** + * Map a `.collection.yml` item kind to the deployment-manifest prompt type. + * @param kind - Item kind from the collection manifest + */ + private mapKindToType(kind: string): 'prompt' | 'instructions' | 'chatmode' | 'agent' | 'skill' { + const kindMap: Record = { + prompt: 'prompt', + instruction: 'instructions', + 'chat-mode': 'chatmode', + agent: 'agent', + skill: 'skill' + }; + return kindMap[kind] ?? 'prompt'; + } + + /** + * Convert kebab-case or space-separated words to Title Case. + * @param str - Input string + */ + private titleCase(str: string): string { + return str + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + } + + /** + * Synthesise a `deployment-manifest.yml` payload from a parsed collection. + * This manifest is embedded into the downloaded ZIP so the bundle installer + * can process it without any changes to the installation pipeline. + * @param collection - Parsed collection manifest + * @param dirName - Bundle directory name (used as fallback ID) + */ + private createDeploymentManifest(collection: CollectionManifest, dirName: string): Record { + const prompts = collection.items.map((item) => { + // Skills are directories — derive the skill name from the directory portion of the path + // and preserve the full path so the installer can locate the skill folder. + if (item.kind === 'skill') { + const skillDirPath = item.path.endsWith('.md') + ? item.path.substring(0, item.path.lastIndexOf('/')) + : item.path; + const skillName = skillDirPath.split('/').pop() ?? 'unknown-skill'; + return { + id: skillName, + name: this.titleCase(skillName.replace(/-/g, ' ')), + description: `Skill from ${collection.name}`, + file: skillDirPath, + type: 'skill' as const, + tags: collection.tags ?? [] + }; + } + + const filename = item.path.split('/').pop() ?? 'unknown'; + const id = filename.replace(/\.(prompt|instructions|chatmode|agent)\.md$/, ''); + return { + id, + name: this.titleCase(id.replace(/-/g, ' ')), + description: `From ${collection.name}`, + file: `prompts/${filename}`, + type: this.mapKindToType(item.kind), + tags: collection.tags ?? [] + }; + }); + + return { + id: collection.id ?? dirName, + name: collection.name, + version: collection.version ?? '1.0.0', + description: collection.description ?? '', + author: collection.author ?? '', + repository: this.source.url, + license: 'Unknown', + tags: collection.tags ?? [], + prompts + }; + } + + /** + * Fetch each file listed in a collection manifest from the ADO repository and + * package them — together with a synthesised `deployment-manifest.yml` — into + * an in-memory ZIP archive. + * + * This is the ADO equivalent of `AwesomeCopilotAdapter.createBundleArchive()`. + * Rather than using the ADO `$format=zip` endpoint (which requires a + * pre-existing directory), this method assembles the archive from individual + * file fetches so that repos using the `.collection.yml` convention do not need + * to maintain any `deployment-manifest.yml` files at all. + * + * **Skill items** are directories rather than single files. For each item with + * `kind === 'skill'`, all blobs under the skill directory are collected from the + * already-fetched `allItems` tree and added to the archive preserving their + * repo-root-relative paths (e.g. `skills/my-skill/SKILL.md`). + * @param collection - Parsed collection manifest + * @param dirName - Bundle directory basename (used as fallback in the manifest) + * @param allItems - Full repository item tree (from {@link fetchFullTree}); used to + * discover skill directory contents without an extra API call + */ + private async createBundleArchive( + collection: CollectionManifest, + dirName: string, + allItems: AdoItem[] + ): Promise { + this.logger.debug(`[AzureDevOpsAdapter] Creating archive for collection: ${collection.name}`); + + return new Promise((resolve, reject) => { + void (async () => { + try { + const archive = archiver('zip', { zlib: { level: 9 } }); + const chunks: Buffer[] = []; + + archive.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + archive.on('finish', () => { + resolve(Buffer.concat(chunks)); + }); + archive.on('error', (err: Error) => { + reject(err); + }); + + // Embed the synthesised deployment manifest + const manifest = this.createDeploymentManifest(collection, dirName); + archive.append(yaml.dump(manifest), { name: 'deployment-manifest.yml' }); + + // Fetch and add each item file + for (const item of collection.items) { + try { + if (item.kind === 'skill') { + // Skills are directories — collect all blobs under the skill dir from + // the already-fetched full tree and add them preserving repo-root paths. + const skillDirPath = item.path.endsWith('.md') + ? item.path.substring(0, item.path.lastIndexOf('/')) + : item.path; + // allItems paths always have a leading '/'; normalise item.path accordingly. + const normalizedSkillDir = skillDirPath.startsWith('/') + ? skillDirPath + : `/${skillDirPath}`; + const skillFiles = allItems.filter( + (treeItem) => !treeItem.isFolder + && treeItem.path.startsWith(normalizedSkillDir + '/') + ); + for (const skillFile of skillFiles) { + const fileContent = await this.fetchFileContent(skillFile.path); + // Strip leading '/' to get a repo-root-relative archive path + archive.append(fileContent, { name: skillFile.path.replace(/^\//, '') }); + this.logger.debug(`[AzureDevOpsAdapter] Added skill file ${skillFile.path} to archive`); + } + } else { + const fileContent = await this.fetchFileContent(item.path); + const filename = item.path.split('/').pop() ?? 'unknown'; + archive.append(fileContent, { name: `prompts/${filename}` }); + this.logger.debug(`[AzureDevOpsAdapter] Added ${filename} to archive`); + } + } catch (err) { + this.logger.warn(`[AzureDevOpsAdapter] Skipping item "${item.path}": ${err}`); + } + } + + void archive.finalize(); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- rejection value is handled by caller + reject(error); + } + })(); + }); + } + + /** + * Build the ADO Items API URL for a specific file in the repository. + * Used as the manifestUrl and downloadUrl for collection-based bundles — the + * `.collection.yml` file itself is the canonical descriptor. + * @param filePath - Repository path of the collection file + */ + private getCollectionFileUrl(filePath: string): string { + const apiBase = this.buildApiBase(); + const params = new URLSearchParams({ + 'versionDescriptor.version': this.branch, + 'api-version': ADO_API_VERSION + }); + return `${apiBase}/items?${params.toString()}&path=${this.encodePath(filePath)}`; + } + + // --------------------------------------------------------------------------- + // IRepositoryAdapter — public methods + // --------------------------------------------------------------------------- + + /** + * Force re-authentication by clearing the cached token. + * The next request will re-attempt the full authentication fallback chain. + */ + public override forceAuthentication(): Promise { + this.logger.info('[AzureDevOpsAdapter] Invalidating cached authentication token'); + this.authToken = undefined; + this.authMethod = 'none'; + return Promise.resolve(); + } + + /** + * Fetch all bundles from the Azure DevOps repository. + * + * Uses the **full-tree blob-scan** strategy for efficient discovery: + * + * 1. **Fetch the full tree** — one `GET /items?recursionLevel=Full` call + * retrieves every file and directory in the repository at once. + * + * 2. **Filter collection blobs** — scan the returned item list in memory for + * `.collection.yml` files that sit exactly one level beneath + * `collectionsPath`. This avoids probing every subdirectory individually. + * + * 3. **Fetch collection content** — for each `.collection.yml` blob found, + * one `GET /items?path=…` call retrieves the file content, which is then + * parsed and converted to a `Bundle`. + * + * Total API calls: **1** (full tree) + **N** (one per discovered bundle). + * @returns Array of discovered bundles + */ + public async fetchBundles(): Promise { + this.logger.info( + `[AzureDevOpsAdapter] Fetching bundles from "${this.source.url}" ` + + `(branch: ${this.branch}, path: ${this.collectionsPath})` + ); + + try { + // ── Step 1: Retrieve the full repository tree in a single API call ────── + const allItems = await this.fetchFullTree(); + + // ── Step 2: Filter for .collection.yml blobs exactly one level deep ───── + const collectionBlobs = this.findCollectionBlobs(allItems); + + this.logger.debug( + `[AzureDevOpsAdapter] Full tree: ${allItems.length} item(s), ` + + `${collectionBlobs.length} collection blob(s) found` + ); + + // ── Step 3: Fetch and parse each collection file ───────────────────────── + const bundles: Bundle[] = []; + + for (const blob of collectionBlobs) { + try { + const fileContent = await this.fetchFileContent(blob.path); + const collection = this.parseCollectionManifest(fileContent, blob.path); + + if (collection) { + // The bundle directory is the parent of the .collection.yml file + const dirPath = blob.path.substring(0, blob.path.lastIndexOf('/')); + const dirName = dirPath.split('/').pop() ?? dirPath; + const bundle = this.buildBundleFromCollection(collection, dirPath, dirName, blob.path); + bundles.push(bundle); + this.logger.debug( + `[AzureDevOpsAdapter] Found bundle: ${bundle.id} ` + + `(${bundle.name} v${bundle.version})` + ); + } + } catch (err) { + // Log and continue — a single bad collection file should not block others + this.logger.warn( + `[AzureDevOpsAdapter] Failed to load collection at "${blob.path}": ${err}` + ); + } + } + + this.logger.info(`[AzureDevOpsAdapter] Discovered ${bundles.length} bundle(s)`); + return bundles; + } catch (error) { + throw new Error(`Failed to fetch bundles from Azure DevOps: ${error}`); + } + } + + /** + * Download a bundle from Azure DevOps as an in-memory ZIP archive. + * + * Rather than using the ADO `$format=zip` endpoint (which requires a + * pre-existing directory with a `deployment-manifest.yml`), this method + * re-fetches the `.collection.yml` for the bundle, then individually + * downloads each listed item and packages them — together with a synthesised + * `deployment-manifest.yml` — into an archive that the standard bundle + * installer can process without modification. + * @param bundle - Bundle to download + * @returns Buffer containing the ZIP archive + */ + public async downloadBundle(bundle: Bundle): Promise { + this.logger.info(`[AzureDevOpsAdapter] Downloading bundle: ${bundle.id}`); + try { + // Recover the collection file path from the bundle's manifestUrl. + // manifestUrl is the ADO Items API URL for the .collection.yml, e.g.: + // https://.../items?...&path=/my-bundle/my-bundle.collection.yml + const collectionFilePath = new URL(bundle.manifestUrl).searchParams.get('path'); + if (!collectionFilePath) { + throw new Error(`Cannot determine collection file path from bundle manifestUrl: "${bundle.manifestUrl}"`); + } + + const dirPath = collectionFilePath.substring(0, collectionFilePath.lastIndexOf('/')); + const dirName = dirPath.split('/').pop() ?? dirPath; + + // The full tree is still needed for skill directory resolution in createBundleArchive. + const allItems = await this.fetchFullTree(); + + const fileContent = await this.fetchFileContent(collectionFilePath); + const collection = this.parseCollectionManifest(fileContent, collectionFilePath); + + if (!collection) { + throw new Error(`Failed to parse collection file at "${collectionFilePath}"`); + } + + return await this.createBundleArchive(collection, dirName, allItems); + } catch (error) { + throw new Error(`Failed to download bundle "${bundle.id}" from Azure DevOps: ${error}`); + } + } + + /** + * Fetch metadata about the Azure DevOps repository. + * @returns Source metadata including repository name, description, and bundle count + */ + public async fetchMetadata(): Promise { + const apiBase = this.buildApiBase(); + const params = new URLSearchParams({ 'api-version': ADO_API_VERSION }); + const requestUrl = `${apiBase}?${params.toString()}`; + + try { + const responseText = await this.fetchString(requestUrl); + const repo = JSON.parse(responseText) as AdoRepository; + const bundles = await this.fetchBundles(); + + return { + name: repo.name, + description: repo.project?.description ?? '', + bundleCount: bundles.length, + lastUpdated: new Date().toISOString(), + version: '1.0.0' + }; + } catch (error) { + throw new Error(`Failed to fetch Azure DevOps metadata: ${error}`); + } + } + + /** + * Validate that the configured Azure DevOps repository is accessible. + * + * Checks that: + * 1. The URL is a valid Azure DevOps repository URL + * 2. The repository API endpoint returns successfully + * 3. At least one bundle was found (warning if none, not an error) + * @returns Validation result + */ + public async validate(): Promise { + if (!this.isValidAdoUrl(this.source.url)) { + return { + valid: false, + errors: [ + `Invalid Azure DevOps URL: "${this.source.url}". ` + + 'Expected format: https://dev.azure.com/{org}/{project}/_git/{repo}' + ], + warnings: [], + bundlesFound: 0 + }; + } + + try { + const apiBase = this.buildApiBase(); + const params = new URLSearchParams({ 'api-version': ADO_API_VERSION }); + await this.fetchString(`${apiBase}?${params.toString()}`); + + const bundles = await this.fetchBundles(); + return { + valid: true, + errors: [], + warnings: bundles.length === 0 + ? [`No bundles found in "${this.collectionsPath}" on branch "${this.branch}"`] + : [], + bundlesFound: bundles.length + }; + } catch (error) { + return { + valid: false, + errors: [`Azure DevOps validation failed: ${error}`], + warnings: [], + bundlesFound: 0 + }; + } + } + + /** + * Get the collection manifest URL for a bundle. + * Bundle IDs are no longer URL-encoded paths, so this returns the ADO repository + * URL as a stable reference. The actual collection file URL is stored in + * `bundle.manifestUrl` and used directly by `downloadBundle`. + * @param _bundleId - Bundle identifier (not used) + * @param _version - Not used; ADO uses branch-based versioning + * @returns ADO repository URL + */ + public getManifestUrl(_bundleId: string, _version?: string): string { + return this.source.url; + } + + /** + * Get the download URL for a bundle. + * @param _bundleId - Bundle identifier (not used) + * @param _version - Not used; ADO uses branch-based versioning + * @returns ADO repository URL + */ + public getDownloadUrl(_bundleId: string, _version?: string): string { + return this.source.url; + } +} diff --git a/src/commands/source-commands.ts b/src/commands/source-commands.ts index 0b726061..392cdca2 100644 --- a/src/commands/source-commands.ts +++ b/src/commands/source-commands.ts @@ -142,6 +142,20 @@ export class SourceCommands { return uris && uris.length > 0 ? uris[0].fsPath : undefined; } + case 'azure-devops': { + return await vscode.window.showInputBox({ + prompt: 'Enter Azure DevOps repository URL', + placeHolder: 'https://dev.azure.com/org/project/_git/repo', + validateInput: (value) => { + if (!value || !value.startsWith('https://') || !value.includes('/_git/')) { + return 'Please enter a valid Azure DevOps HTTPS URL containing /_git/'; + } + return undefined; + }, + ignoreFocusOut: true + }); + } + default: { return undefined; } @@ -329,6 +343,11 @@ export class SourceCommands { label: '$(folder-library) Local Skills', description: 'Local filesystem directory with skills in skills/ folder (SKILL.md files)', value: 'local-skills' as SourceType + }, + { + label: '$(azure) Azure DevOps', + description: 'Azure DevOps Git repository (cloud or on-premises) with .collection.yml bundles', + value: 'azure-devops' as SourceType } ], { @@ -416,6 +435,28 @@ export class SourceCommands { branch: branch || 'main' }; + break; + } + case 'azure-devops': { + const branch = await vscode.window.showInputBox({ + prompt: 'Enter branch name (or press Enter for "main")', + placeHolder: 'main', + value: 'main', + ignoreFocusOut: true + }); + + const collectionsPath = await vscode.window.showInputBox({ + prompt: 'Enter bundles root path within the repository (or press Enter for "/")', + placeHolder: '/', + value: '/', + ignoreFocusOut: true + }); + + config = { + branch: branch || 'main', + collectionsPath: collectionsPath || '/' + }; + break; } // No default diff --git a/src/services/azure-devops-auth.ts b/src/services/azure-devops-auth.ts new file mode 100644 index 00000000..8d8471f4 --- /dev/null +++ b/src/services/azure-devops-auth.ts @@ -0,0 +1,228 @@ +/** + * Azure DevOps Authentication Service + * + * Resolves authentication tokens for Azure DevOps REST API access. + * Supports three authentication methods tried in priority order: + * + * 1. **Personal Access Token (PAT)** from source configuration + * - Configure via `promptregistry.sources[].token` setting + * - Simplest and most explicit method; works in all environments + * - Generates a Basic `Authorization: Basic base64(":"+PAT)` header + * - Create a PAT at https://dev.azure.com/{org}/_usersettings/tokens + * with at minimum "Code (read)" scope + * + * 2. **VS Code Microsoft authentication** (built-in, no CLI required) + * - Uses the VS Code built-in `microsoft` authentication provider + * - Prompts the user silently if they are already signed in to VS Code + * with a Microsoft/Entra account that has access to the ADO org + * - Token is scoped to the Azure DevOps resource via `.default` scope + * - Generates a Bearer `Authorization: Bearer ` header + * - No extra software needed — works out of the box in VS Code + * + * 3. **Azure CLI token** (`az account get-access-token`) + * - Falls back to the Azure CLI if VS Code auth is not available + * - Requires Azure CLI to be installed and `az login` to have been run + * - Useful in headless / CI/CD environments where VS Code auth is absent + * - Generates a Bearer `Authorization: Bearer ` header + * + * Authentication Setup Guides: + * - PAT: https://dev.azure.com/{org}/_usersettings/tokens + * - VS Code auth: Sign in to VS Code with your Microsoft/Entra account + * - Azure CLI: Run `az login` then `az account set --subscription ` + */ + +import { + execFile, +} from 'node:child_process'; +import { + promisify, +} from 'node:util'; +import * as vscode from 'vscode'; +import { + Logger, +} from '../utils/logger'; + +const execFileAsync = promisify(execFile); + +/** + * Well-known Azure DevOps resource (client application) ID. + * Used when requesting tokens scoped to Azure DevOps Services. + * + * - Azure CLI: passed as `--resource` to `az account get-access-token` + * - VS Code: used as the scope prefix in the `.default` scope pattern + */ +const ADO_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; + +/** + * OAuth 2.0 `.default` scope for Azure DevOps Services. + * + * Using `.default` tells the identity platform to issue a token with all + * the permissions that have been granted to the caller for the ADO resource. + * This is the correct scope format for the VS Code `microsoft` auth provider. + */ +const ADO_VSCODE_SCOPES = [`${ADO_RESOURCE_ID}/.default`]; + +/** + * Authentication method identifiers — reflects how the token was obtained. + * + * | Value | Header format | + * |--------------------|---------------------------------------------| + * | `'pat'` | `Authorization: Basic base64(":"+PAT)` | + * | `'vscode-microsoft'` | `Authorization: Bearer ` | + * | `'az-cli'` | `Authorization: Bearer ` | + * | `'none'` | No Authorization header | + */ +export type AzureDevOpsAuthMethod = 'pat' | 'vscode-microsoft' | 'az-cli' | 'none'; + +/** + * Resolved authentication result returned by {@link AzureDevOpsAuthService.getToken}. + */ +export interface AzureDevOpsAuthResult { + /** The resolved access token string */ + token: string; + /** How the token was obtained */ + method: AzureDevOpsAuthMethod; +} + +/** + * Azure DevOps authentication service. + * + * Implements a three-step fallback chain for resolving authentication tokens: + * 1. Explicit PAT configured on the source → `Authorization: Basic …` + * 2. VS Code Microsoft auth session → `Authorization: Bearer …` + * 3. Azure CLI `az account get-access-token`→ `Authorization: Bearer …` + * @example + * ```typescript + * const auth = new AzureDevOpsAuthService(); + * const result = await auth.getToken(source.token); + * if (result) { + * headers['Authorization'] = auth.buildAuthHeader(result.token, result.method); + * } + * ``` + */ +export class AzureDevOpsAuthService { + private readonly logger: Logger; + + constructor() { + this.logger = Logger.getInstance(); + } + + /** + * Resolve an access token for Azure DevOps API requests. + * + * Tries each authentication method in order and returns as soon as one + * succeeds. Returns `undefined` when no method is available (public repos + * may still work without authentication). + * + * **Fallback chain** + * 1. PAT — `explicitToken` parameter (from `source.token` in config) + * 2. VS Code Microsoft auth — silent session via `vscode.authentication` + * 3. Azure CLI — `az account get-access-token --resource ` + * @param explicitToken - Optional Personal Access Token from source configuration + * @returns Resolved token + method pair, or `undefined` if no auth is available + */ + public async getToken(explicitToken?: string): Promise { + // ── Step 1: PAT from source configuration ─────────────────────────────────── + // This is the simplest and most explicit option. If the user has set + // `source.token` to a valid PAT, we use it immediately without any + // external calls. + if (explicitToken && explicitToken.trim().length > 0) { + this.logger.info('[AzureDevOpsAuth] ✓ Using explicit PAT from configuration'); + return { token: explicitToken.trim(), method: 'pat' }; + } + + // ── Step 2: VS Code Microsoft authentication ───────────────────────────────── + // The VS Code `microsoft` provider can issue tokens for Azure DevOps + // Services without any CLI tooling. We use `{ silent: true }` so we + // only pick up an *existing* session and never show an interactive + // login dialog during background bundle discovery. + // + // If the user is signed in to VS Code with an Entra/AAD account that + // has access to the target ADO organisation, this will succeed silently. + try { + this.logger.debug('[AzureDevOpsAuth] Trying VS Code Microsoft authentication...'); + const session = await vscode.authentication.getSession( + 'microsoft', // VS Code built-in Microsoft/Entra ID provider + ADO_VSCODE_SCOPES, // scoped to Azure DevOps Services resource + { silent: true } // never prompt interactively during auto-discovery + ); + if (session) { + this.logger.info('[AzureDevOpsAuth] ✓ Using VS Code Microsoft authentication'); + return { token: session.accessToken, method: 'vscode-microsoft' }; + } + this.logger.debug('[AzureDevOpsAuth] VS Code Microsoft auth: no active session found'); + } catch (error) { + // Swallow — VS Code auth not available (headless env, test runner, etc.) + this.logger.debug(`[AzureDevOpsAuth] VS Code Microsoft auth failed: ${error}`); + } + + // ── Step 3: Azure CLI token ────────────────────────────────────────────────── + // Last resort for environments where the Azure CLI is installed and + // `az login` has been run (e.g. developer machines, CI pipelines with + // an Azure service principal). + try { + this.logger.debug('[AzureDevOpsAuth] Trying Azure CLI token...'); + const { stdout } = await execFileAsync('az', [ + 'account', 'get-access-token', + '--resource', ADO_RESOURCE_ID, + '--query', 'accessToken', + '--output', 'tsv' + ]); + const token = stdout.trim(); + if (token && token.length > 0) { + this.logger.info('[AzureDevOpsAuth] ✓ Using Azure CLI access token'); + return { token, method: 'az-cli' }; + } + this.logger.debug('[AzureDevOpsAuth] Azure CLI returned empty token'); + } catch (error) { + this.logger.debug(`[AzureDevOpsAuth] Azure CLI auth failed: ${error}`); + } + + // No authentication available + this.logger.warn( + '[AzureDevOpsAuth] ✗ No authentication available — private repos will be inaccessible. ' + + 'Options: (1) set source.token to a PAT with "Code (read)" scope, ' + + '(2) sign in to VS Code with your Microsoft account, or ' + + '(3) run `az login` to authenticate with Azure CLI.' + ); + return undefined; + } + + /** + * Build the `Authorization` HTTP header value for a resolved token. + * + * **PAT tokens** use HTTP Basic auth with an **empty username** and the PAT + * as the password. Azure DevOps ignores the username field entirely — only + * the password (PAT) matters. The header looks like: + * ``` + * Authorization: Basic base64(":myPersonalAccessToken") + * ``` + * Note the leading colon — that encodes the empty username. + * + * **Bearer tokens** (VS Code Microsoft auth, Azure CLI) use the standard + * OAuth 2.0 bearer scheme: + * ``` + * Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI... + * ``` + * @param token - The resolved access token string (must be non-empty after trimming) + * @param method - How the token was obtained (determines the header scheme) + * @returns Full `Authorization` header value, ready to set on an HTTP request + * @throws {Error} If `token` is empty after trimming + */ + public buildAuthHeader(token: string, method: AzureDevOpsAuthMethod): string { + const trimmed = token.trim(); + if (trimmed.length === 0) { + throw new Error('[AzureDevOpsAuth] Cannot build Authorization header: token is empty'); + } + if (method === 'pat') { + // Azure DevOps PAT: Basic auth with empty username, PAT as password. + // The format is base64(":" + token) — the leading colon encodes the + // empty username slot. + const credentials = Buffer.from(`:${trimmed}`).toString('base64'); + return `Basic ${credentials}`; + } + // VS Code Microsoft tokens and Azure CLI tokens are OAuth Bearer tokens. + // Both 'vscode-microsoft' and 'az-cli' use the same Bearer scheme. + return `Bearer ${trimmed}`; + } +} diff --git a/src/services/registry-manager.ts b/src/services/registry-manager.ts index 5d9a8a20..a20e5b98 100644 --- a/src/services/registry-manager.ts +++ b/src/services/registry-manager.ts @@ -10,6 +10,9 @@ import { import { AwesomeCopilotAdapter, } from '../adapters/awesome-copilot-adapter'; +import { + AzureDevOpsAdapter, +} from '../adapters/azure-devops-adapter'; import { GitHubAdapter, } from '../adapters/github-adapter'; @@ -189,6 +192,7 @@ export class RegistryManager { RepositoryAdapterFactory.register('apm', ApmAdapter); RepositoryAdapterFactory.register('skills', SkillsAdapter); RepositoryAdapterFactory.register('local-skills', LocalSkillsAdapter); + RepositoryAdapterFactory.register('azure-devops', AzureDevOpsAdapter); } /** diff --git a/src/types/registry.ts b/src/types/registry.ts index df729464..f5bd5344 100644 --- a/src/types/registry.ts +++ b/src/types/registry.ts @@ -8,7 +8,7 @@ import { /** * Registry source types */ -export type SourceType = 'github' | 'local' | 'awesome-copilot' | 'local-awesome-copilot' | 'apm' | 'local-apm' | 'skills' | 'local-skills'; +export type SourceType = 'github' | 'local' | 'awesome-copilot' | 'local-awesome-copilot' | 'apm' | 'local-apm' | 'skills' | 'local-skills' | 'azure-devops'; /** * Installation scope diff --git a/test/adapters/azure-devops-adapter.test.ts b/test/adapters/azure-devops-adapter.test.ts new file mode 100644 index 00000000..6e357e2e --- /dev/null +++ b/test/adapters/azure-devops-adapter.test.ts @@ -0,0 +1,631 @@ +/** + * AzureDevOpsAdapter Unit Tests + */ + +import * as assert from 'node:assert'; +import nock from 'nock'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { + AzureDevOpsAdapter, +} from '../../src/adapters/azure-devops-adapter'; +import { + RegistrySource, +} from '../../src/types/registry'; + +suite('AzureDevOpsAdapter', () => { + let sandbox: sinon.SinonSandbox; + + const mockSource: RegistrySource = { + id: 'test-ado-source', + name: 'Test ADO Source', + type: 'azure-devops', + url: 'https://dev.azure.com/myorg/myproject/_git/myrepo', + enabled: true, + priority: 1, + private: true, + token: 'mypat', + config: { + branch: 'main', + collectionsPath: '/' + } + }; + + const apiBase = 'https://dev.azure.com'; + const repoApiPath = '/myorg/myproject/_apis/git/repositories/myrepo'; + + const makeCollectionYaml = (opts: { + id?: string; + name?: string; + version?: string; + description?: string; + author?: string; + tags?: string[]; + items?: { path: string; kind: string }[]; + } = {}): string => { + const { + id = 'my-collection', + name = 'My Collection', + version = '1.0.0', + description = 'A test collection', + author = 'Test Author', + tags = ['test'], + items = [{ path: 'prompts/hello.prompt.md', kind: 'prompt' }] + } = opts; + const tagLines = tags.map((t) => ` - ${t}`).join('\n'); + const itemLines = items.map((i) => ` - path: ${i.path}\n kind: ${i.kind}`).join('\n'); + return [ + `id: ${id}`, + `name: ${name}`, + `version: ${version}`, + `description: ${description}`, + `author: ${author}`, + 'tags:', + tagLines, + 'items:', + itemLines + ].join('\n'); + }; + + setup(() => { + sandbox = sinon.createSandbox(); + // Default: VS Code Microsoft auth returns no session (PAT in mockSource is used instead) + sandbox.stub(vscode.authentication, 'getSession').resolves(undefined); + }); + + teardown(() => { + sandbox.restore(); + nock.cleanAll(); + }); + + // --------------------------------------------------------------------------- + // Constructor / URL validation + // --------------------------------------------------------------------------- + + suite('Constructor and URL Validation', () => { + test('should accept a valid ADO Services URL', () => { + const adapter = new AzureDevOpsAdapter(mockSource); + assert.strictEqual(adapter.type, 'azure-devops'); + }); + + test('should accept a visualstudio.com URL', () => { + const source = { + ...mockSource, + url: 'https://myorg.visualstudio.com/myproject/_git/myrepo' + }; + assert.doesNotThrow(() => new AzureDevOpsAdapter(source)); + }); + + test('should accept an on-premises server URL', () => { + const source = { + ...mockSource, + url: 'https://ado.mycompany.com/DefaultCollection/myproject/_git/myrepo' + }; + assert.doesNotThrow(() => new AzureDevOpsAdapter(source)); + }); + + test('should throw for a URL without /_git/', () => { + const source = { ...mockSource, url: 'https://github.com/org/repo' }; + assert.throws(() => new AzureDevOpsAdapter(source), /Invalid Azure DevOps URL/); + }); + + test('should throw for a non-HTTP URL', () => { + const source = { ...mockSource, url: 'git@ssh.dev.azure.com:v3/org/project/repo' }; + assert.throws(() => new AzureDevOpsAdapter(source), /Invalid Azure DevOps URL/); + }); + + test('should throw for a plain HTTP URL (credentials must use HTTPS)', () => { + const source = { ...mockSource, url: 'http://dev.azure.com/myorg/myproject/_git/myrepo' }; + assert.throws(() => new AzureDevOpsAdapter(source), /Invalid Azure DevOps URL/); + }); + }); + + // --------------------------------------------------------------------------- + // fetchBundles — collection-blob discovery strategy + // --------------------------------------------------------------------------- + + suite('fetchBundles()', () => { + test('should discover a bundle from a .collection.yml blob in a single full-tree call', async () => { + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .reply(200, { + count: 3, + value: [ + { objectId: 'root', gitObjectType: 'tree', commitId: 'c1', path: '/', isFolder: true }, + { objectId: 'dir1', gitObjectType: 'tree', commitId: 'c1', path: '/my-bundle', isFolder: true }, + { objectId: 'coll1', gitObjectType: 'blob', commitId: 'c1', path: '/my-bundle/my-bundle.collection.yml', isFolder: false } + ] + }); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === '/my-bundle/my-bundle.collection.yml') + .reply(200, makeCollectionYaml({ name: 'My Bundle', version: '1.2.0', author: 'Test Author', tags: ['test'] })); + + const adapter = new AzureDevOpsAdapter(mockSource); + const bundles = await adapter.fetchBundles(); + + assert.strictEqual(bundles.length, 1); + assert.strictEqual(bundles[0].name, 'My Bundle'); + assert.strictEqual(bundles[0].version, '1.2.0'); + assert.strictEqual(bundles[0].author, 'Test Author'); + assert.deepStrictEqual(bundles[0].tags, ['test']); + // Bundle ID for depth-1 layout must equal the collectionId from the manifest. + assert.strictEqual( + bundles[0].id, + 'my-collection', + 'depth-1 bundle ID must equal the collectionId from the manifest' + ); + }); + + test('should skip directories that have no .collection.yml blob in the tree', async () => { + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .reply(200, { + count: 2, + value: [ + { objectId: 'root', gitObjectType: 'tree', commitId: 'c1', path: '/', isFolder: true }, + { objectId: 'dir1', gitObjectType: 'tree', commitId: 'c1', path: '/no-collection', isFolder: true } + ] + }); + + const adapter = new AzureDevOpsAdapter(mockSource); + const bundles = await adapter.fetchBundles(); + + assert.strictEqual(bundles.length, 0); + }); + + test('should skip .collection.yml blobs nested more than one level deep', async () => { + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .reply(200, { + count: 4, + value: [ + { objectId: 'r', gitObjectType: 'tree', commitId: 'c1', path: '/', isFolder: true }, + { objectId: 'd1', gitObjectType: 'tree', commitId: 'c1', path: '/outer', isFolder: true }, + { objectId: 'd2', gitObjectType: 'tree', commitId: 'c1', path: '/outer/inner', isFolder: true }, + { objectId: 'coll', gitObjectType: 'blob', commitId: 'c1', path: '/outer/inner/nested.collection.yml', isFolder: false } + ] + }); + + const adapter = new AzureDevOpsAdapter(mockSource); + const bundles = await adapter.fetchBundles(); + + assert.strictEqual(bundles.length, 0); + }); + + test('should discover a bundle from a .collection.yml blob at depth-0 (flat layout)', async () => { + // Flat layout: collections/ directly contains .collection.yml (depth-0). + // The collection file is at /collections/my-collection.collection.yml — exactly + // one segment beneath the collectionsPath base. + const source = { + ...mockSource, + config: { branch: 'main', collectionsPath: '/collections' } + }; + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .reply(200, { + count: 5, + value: [ + { objectId: 'r', gitObjectType: 'tree', commitId: 'c1', path: '/', isFolder: true }, + { objectId: 'cd', gitObjectType: 'tree', commitId: 'c1', path: '/collections', isFolder: true }, + { objectId: 'cf', gitObjectType: 'blob', commitId: 'c1', path: '/collections/my-collection.collection.yml', isFolder: false }, + { objectId: 'pd', gitObjectType: 'tree', commitId: 'c1', path: '/prompts', isFolder: true }, + { objectId: 'pf', gitObjectType: 'blob', commitId: 'c1', path: '/prompts/task-helper.prompt.md', isFolder: false } + ] + }); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === '/collections/my-collection.collection.yml') + .reply(200, makeCollectionYaml({ + id: 'my-collection', + name: 'My Flat Collection', + version: '2.0.0', + items: [{ path: 'prompts/task-helper.prompt.md', kind: 'prompt' }] + })); + + const adapter = new AzureDevOpsAdapter(source); + const bundles = await adapter.fetchBundles(); + + assert.strictEqual(bundles.length, 1); + assert.strictEqual(bundles[0].name, 'My Flat Collection'); + assert.strictEqual(bundles[0].version, '2.0.0'); + // Bundle ID must equal the collectionId from the manifest. + assert.strictEqual( + bundles[0].id, + 'my-collection', + 'depth-0 bundle ID must equal the collectionId from the manifest' + ); + }); + + test('should discover multiple bundles from multiple .collection.yml blobs', async () => { + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .reply(200, { + count: 5, + value: [ + { objectId: 'r', gitObjectType: 'tree', commitId: 'c1', path: '/', isFolder: true }, + { objectId: 'd1', gitObjectType: 'tree', commitId: 'c1', path: '/bundle-a', isFolder: true }, + { objectId: 'c1', gitObjectType: 'blob', commitId: 'c1', path: '/bundle-a/bundle-a.collection.yml', isFolder: false }, + { objectId: 'd2', gitObjectType: 'tree', commitId: 'c1', path: '/bundle-b', isFolder: true }, + { objectId: 'c2', gitObjectType: 'blob', commitId: 'c1', path: '/bundle-b/bundle-b.collection.yml', isFolder: false } + ] + }); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === '/bundle-a/bundle-a.collection.yml') + .reply(200, makeCollectionYaml({ name: 'Bundle A', version: '1.0.0' })); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === '/bundle-b/bundle-b.collection.yml') + .reply(200, makeCollectionYaml({ name: 'Bundle B', version: '2.0.0' })); + + const adapter = new AzureDevOpsAdapter(mockSource); + const bundles = await adapter.fetchBundles(); + + assert.strictEqual(bundles.length, 2); + const names = bundles.map((b) => b.name).toSorted(); + assert.deepStrictEqual(names, ['Bundle A', 'Bundle B']); + }); + + test('should scope results to collectionsPath when configured', async () => { + const source = { + ...mockSource, + config: { branch: 'main', collectionsPath: '/bundles' } + }; + + // Tree contains a .collection.yml inside /bundles (should be discovered) + // and one outside /bundles (should be ignored) + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .reply(200, { + count: 4, + value: [ + { objectId: 'r', gitObjectType: 'tree', commitId: 'c1', path: '/', isFolder: true }, + { objectId: 'd1', gitObjectType: 'tree', commitId: 'c1', path: '/bundles', isFolder: true }, + { objectId: 'd2', gitObjectType: 'tree', commitId: 'c1', path: '/bundles/my-bundle', isFolder: true }, + { objectId: 'c1', gitObjectType: 'blob', commitId: 'c1', path: '/bundles/my-bundle/my-bundle.collection.yml', isFolder: false } + ] + }); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === '/bundles/my-bundle/my-bundle.collection.yml') + .reply(200, makeCollectionYaml({ name: 'In Bundles' })); + + const adapter = new AzureDevOpsAdapter(source); + const bundles = await adapter.fetchBundles(); + + assert.strictEqual(bundles.length, 1); + assert.strictEqual(bundles[0].name, 'In Bundles'); + }); + + test('should always omit path param from tree fetch regardless of collectionsPath', async () => { + for (const cp of ['/', '/skills', '/prompts/bundles']) { + const source = { ...mockSource, config: { branch: 'main', collectionsPath: cp } }; + let capturedQuery: Record = {}; + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => { + capturedQuery = q as Record; + return !q.path && q.recursionLevel === 'Full'; + }) + .reply(200, { count: 0, value: [] }); + + const adapter = new AzureDevOpsAdapter(source); + await adapter.fetchBundles(); + + assert.strictEqual(capturedQuery.path, undefined, + `path query param must be absent for collectionsPath="${cp}"`); + } + }); + + test('should throw when ADO API returns an error', async () => { + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .reply(401, { message: 'Unauthorized' }); + + const adapter = new AzureDevOpsAdapter(mockSource); + await assert.rejects( + () => adapter.fetchBundles(), + /Failed to fetch bundles from Azure DevOps/ + ); + }); + + test('should use Bearer token from VS Code Microsoft auth when no PAT is set', async () => { + const sourceNoPat = { ...mockSource, token: undefined }; + const mockToken = 'vscode-microsoft-bearer-token'; + + (vscode.authentication.getSession as sinon.SinonStub).callsFake( + (providerId: string) => { + if (providerId === 'microsoft') { + return Promise.resolve({ + accessToken: mockToken, + id: 'session-id', + scopes: ['499b84ac-1321-427f-aa17-267ca6975798/.default'], + account: { id: 'user', label: 'user@example.com' } + }); + } + return Promise.resolve(undefined); + } + ); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .matchHeader('Authorization', `Bearer ${mockToken}`) + .reply(200, { count: 0, value: [] }); + + const adapter = new AzureDevOpsAdapter(sourceNoPat); + const bundles = await adapter.fetchBundles(); + + assert.strictEqual(bundles.length, 0); + }); + }); + + // --------------------------------------------------------------------------- + // downloadBundle — in-memory ZIP assembly + // --------------------------------------------------------------------------- + + suite('downloadBundle()', () => { + test('should fetch .collection.yml and assemble a ZIP containing item files and a deployment manifest', async () => { + const collectionYaml = makeCollectionYaml({ + name: 'Download Test', + items: [{ path: 'prompts/hello.prompt.md', kind: 'prompt' }] + }); + + // 1. fetchBundles needs the full tree + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .twice() // called once for fetchBundles, once inside downloadBundle + .reply(200, { + count: 3, + value: [ + { objectId: 'r', gitObjectType: 'tree', commitId: 'c1', path: '/', isFolder: true }, + { objectId: 'd1', gitObjectType: 'tree', commitId: 'c1', path: '/my-bundle', isFolder: true }, + { objectId: 'coll', gitObjectType: 'blob', commitId: 'c1', path: '/my-bundle/my-bundle.collection.yml', isFolder: false } + ] + }); + + // 2. fetchBundles fetches the collection YAML + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === '/my-bundle/my-bundle.collection.yml') + .twice() // once for fetchBundles, once for downloadBundle + .reply(200, collectionYaml); + + // 3. downloadBundle fetches each item file + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === 'prompts/hello.prompt.md') + .reply(200, '# Hello prompt'); + + const adapter = new AzureDevOpsAdapter(mockSource); + const bundles = await adapter.fetchBundles(); + assert.strictEqual(bundles.length, 1); + + const zipBuffer = await adapter.downloadBundle(bundles[0]); + + assert.ok(Buffer.isBuffer(zipBuffer), 'downloadBundle should return a Buffer'); + assert.ok(zipBuffer.length > 0, 'ZIP buffer should not be empty'); + // ZIP magic number PK (0x504B) + assert.strictEqual(zipBuffer[0], 0x50); + assert.strictEqual(zipBuffer[1], 0x4B); + }); + + test('should fetch all files in a skill directory and include them in the ZIP', async () => { + const collectionYaml = makeCollectionYaml({ + name: 'Skill Test', + items: [{ path: 'skills/my-skill', kind: 'skill' }] + }); + + // Full tree includes the skill directory contents + const fullTree = { + count: 6, + value: [ + { objectId: 'r', gitObjectType: 'tree', commitId: 'c1', path: '/', isFolder: true }, + { objectId: 'd1', gitObjectType: 'tree', commitId: 'c1', path: '/my-bundle', isFolder: true }, + { objectId: 'coll', gitObjectType: 'blob', commitId: 'c1', path: '/my-bundle/my-bundle.collection.yml', isFolder: false }, + { objectId: 'sd', gitObjectType: 'tree', commitId: 'c1', path: '/skills/my-skill', isFolder: true }, + { objectId: 'sm', gitObjectType: 'blob', commitId: 'c1', path: '/skills/my-skill/SKILL.md', isFolder: false }, + { objectId: 'ss', gitObjectType: 'blob', commitId: 'c1', path: '/skills/my-skill/scripts/run.sh', isFolder: false } + ] + }; + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .twice() + .reply(200, fullTree); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === '/my-bundle/my-bundle.collection.yml') + .twice() + .reply(200, collectionYaml); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === '/skills/my-skill/SKILL.md') + .reply(200, '# My Skill\n'); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === '/skills/my-skill/scripts/run.sh') + .reply(200, '#!/bin/sh\n'); + + const adapter = new AzureDevOpsAdapter(mockSource); + const bundles = await adapter.fetchBundles(); + assert.strictEqual(bundles.length, 1); + + const zipBuffer = await adapter.downloadBundle(bundles[0]); + + assert.ok(Buffer.isBuffer(zipBuffer), 'downloadBundle should return a Buffer'); + assert.ok(zipBuffer.length > 0, 'ZIP buffer should not be empty'); + // ZIP magic number PK (0x504B) + assert.strictEqual(zipBuffer[0], 0x50); + assert.strictEqual(zipBuffer[1], 0x4B); + }); + }); + + // --------------------------------------------------------------------------- + // fetchMetadata + // --------------------------------------------------------------------------- + + suite('fetchMetadata()', () => { + test('should return metadata from the repository API', async () => { + nock(apiBase) + .get(`${repoApiPath}`) + .query(true) + .reply(200, { + id: 'repo-id', + name: 'myrepo', + project: { name: 'myproject', description: 'My project description' }, + remoteUrl: mockSource.url + }); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .reply(200, { count: 0, value: [] }); + + const adapter = new AzureDevOpsAdapter(mockSource); + const metadata = await adapter.fetchMetadata(); + + assert.strictEqual(metadata.name, 'myrepo'); + assert.strictEqual(metadata.bundleCount, 0); + }); + + test('should throw when the repository API fails', async () => { + nock(apiBase) + .get(`${repoApiPath}`) + .query(true) + .reply(404, { message: 'Not Found' }); + + const adapter = new AzureDevOpsAdapter(mockSource); + await assert.rejects( + () => adapter.fetchMetadata(), + /Failed to fetch Azure DevOps metadata/ + ); + }); + }); + + // --------------------------------------------------------------------------- + // validate + // --------------------------------------------------------------------------- + + suite('validate()', () => { + test('should return valid when the repository is accessible and bundles exist', async () => { + nock(apiBase) + .get(`${repoApiPath}`) + .query(true) + .reply(200, { id: 'r', name: 'myrepo', project: { name: 'p' }, remoteUrl: '' }); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .reply(200, { + count: 3, + value: [ + { objectId: 'r', gitObjectType: 'tree', commitId: 'c1', path: '/', isFolder: true }, + { objectId: 'd1', gitObjectType: 'tree', commitId: 'c1', path: '/bundle1', isFolder: true }, + { objectId: 'c1', gitObjectType: 'blob', commitId: 'c1', path: '/bundle1/bundle1.collection.yml', isFolder: false } + ] + }); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => q.path === '/bundle1/bundle1.collection.yml') + .reply(200, makeCollectionYaml({ name: 'Bundle 1' })); + + const adapter = new AzureDevOpsAdapter(mockSource); + const result = await adapter.validate(); + + assert.strictEqual(result.valid, true); + assert.strictEqual(result.errors.length, 0); + assert.strictEqual(result.bundlesFound, 1); + }); + + test('should return valid with warning when no bundles found', async () => { + nock(apiBase) + .get(`${repoApiPath}`) + .query(true) + .reply(200, { id: 'r', name: 'myrepo', project: { name: 'p' }, remoteUrl: '' }); + + nock(apiBase) + .get(`${repoApiPath}/items`) + .query((q) => !q.path && q.recursionLevel === 'Full') + .reply(200, { count: 0, value: [] }); + + const adapter = new AzureDevOpsAdapter(mockSource); + const result = await adapter.validate(); + + assert.strictEqual(result.valid, true); + assert.strictEqual(result.warnings.length, 1); + assert.ok(result.warnings[0].includes('No bundles found')); + }); + + test('should return invalid when URL is malformed', () => { + const source = { ...mockSource, url: 'https://github.com/org/repo' }; + assert.throws(() => new AzureDevOpsAdapter(source), /Invalid Azure DevOps URL/); + }); + + test('should return invalid when the API returns an error', async () => { + nock(apiBase) + .get(`${repoApiPath}`) + .query(true) + .reply(403, { message: 'Forbidden' }); + + const adapter = new AzureDevOpsAdapter(mockSource); + const result = await adapter.validate(); + + assert.strictEqual(result.valid, false); + assert.ok(result.errors.length > 0); + }); + }); + + // --------------------------------------------------------------------------- + // getManifestUrl / getDownloadUrl + // --------------------------------------------------------------------------- + + suite('getManifestUrl() / getDownloadUrl()', () => { + test('should return the source repository URL', () => { + const adapter = new AzureDevOpsAdapter(mockSource); + const bundleId = 'my-collection'; + + const manifestUrl = adapter.getManifestUrl(bundleId); + assert.strictEqual(manifestUrl, mockSource.url, 'manifest URL should be the source repository URL'); + + const downloadUrl = adapter.getDownloadUrl(bundleId); + assert.strictEqual(downloadUrl, mockSource.url, 'download URL should be the source repository URL'); + }); + }); + + // --------------------------------------------------------------------------- + // requiresAuthentication + // --------------------------------------------------------------------------- + + suite('requiresAuthentication()', () => { + test('should return true when source is marked private', () => { + const adapter = new AzureDevOpsAdapter({ ...mockSource, private: true }); + assert.strictEqual(adapter.requiresAuthentication(), true); + }); + + test('should return false when source is not private', () => { + const adapter = new AzureDevOpsAdapter({ ...mockSource, private: false }); + assert.strictEqual(adapter.requiresAuthentication(), false); + }); + }); +});