diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..42a3ecb --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# Code owners for template/stable. +# +# GitHub uses this file (combined with the branch's +# `require_code_owner_reviews` protection rule) to enforce that every PR +# targeting this branch is approved by at least one listed owner. The line +# below applies repo-wide; either owner can satisfy the requirement. +* @Yimin-Jin @huimiu diff --git a/.github/scripts/generate_sample_catalog.mjs b/.github/scripts/generate_sample_catalog.mjs new file mode 100644 index 0000000..75e63c4 --- /dev/null +++ b/.github/scripts/generate_sample_catalog.mjs @@ -0,0 +1,894 @@ +// @ts-check +/** + * Generate samples/hosted-agent/sample-catalog.json from the foundry-samples repository. + * + * Walks the hosted-agents tree in microsoft-foundry/foundry-samples once via + * the git-tree API, parses each sample's azure.yaml (the azd service + * manifest) for protocol and model requirements, derives displayName from the + * directory name, and — when AZURE_OPENAI_* secrets are set — fills the + * description with a short LLM-generated sentence sourced from the sample's + * README.md. + * + * Usage: + * node generate_sample_catalog.mjs + * + * Environment variables: + * GITHUB_TOKEN Optional GitHub token for API authentication. + * REPO_ROOT Repository root (defaults to two levels up from this script). + * SAMPLES_REPO_URL Source repo URL (defaults to https://github.com/microsoft-foundry/foundry-samples/). + * AZURE_OPENAI_* Optional; when set, descriptions are LLM-generated. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const REPO_ROOT = process.env.REPO_ROOT || resolve(__dirname, '..', '..'); +const SAMPLES_REPO_URL = process.env.SAMPLES_REPO_URL || 'https://github.com/microsoft-foundry/foundry-samples/'; +const SAMPLES_REPO_API = 'https://api.github.com/repos/microsoft-foundry/foundry-samples'; +const OUTPUT_PATH = join(REPO_ROOT, 'samples', 'hosted-agent', 'sample-catalog.json'); +const OVERRIDES_PATH = join(REPO_ROOT, 'samples', 'hosted-agent', 'sample-overrides.json'); + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; + +// Languages, frameworks, and protocols are discovered dynamically from the +// samples repo tree (see discoverLanguagesAndFrameworks / parseAzureYaml) +// instead of being restricted by an allowlist. These blacklists are the +// explicit escape hatch to exclude a specific discovered value; empty by +// default means "no restriction" — everything discovered is kept. +/** @type {string[]} */ +const BLOCKED_LANGUAGES = []; +/** @type {string[]} */ +const BLOCKED_FRAMEWORKS = []; +/** @type {string[]} */ +const BLOCKED_PROTOCOLS = []; + +/** @type {Record}>} */ +const DIMENSION_DEFAULTS = { + language: { + title: 'Select a Language', + placeholder: 'Choose the language for your agent', + options: { + python: 'Python', + csharp: 'C# (.NET)', + }, + }, + framework: { + title: 'Select a Framework', + placeholder: 'Choose the framework for your agent', + // Insertion order here drives the option order in the generated catalog + // (see buildDimensions). Keep the most actively promoted framework first. + options: { + 'copilot-sdk': 'Copilot SDK', + 'agent-framework': 'Agent Framework', + 'bring-your-own': 'Bring Your Own', + 'langgraph': 'LangGraph', + }, + }, + protocol: { + title: 'Select a Protocol', + placeholder: 'Choose the protocol for your agent', + options: { + responses: 'Responses API', + invocations: 'Invocations API', + invocations_ws: 'Invocations (WebSocket)', + }, + }, +}; + +const TEMPLATE_SELECTION = { + title: 'Select a Template', + placeholder: 'Choose a template for your agent', +}; + +// Path segments must be alphanumeric, hyphens, underscores, or dots +const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9._-]+$/; + +// Category segments to EXCLUDE at the position immediately under a framework +// (`//...`). This is a BLACK-LIST: empty by default means +// every discovered category is surfaced; add a segment here to drop an upstream +// grouping you don't want in the picker yet. Flat templates that sit directly +// under a framework (e.g. csharp `agent-framework/hello-world`) have no category +// segment and are always surfaced (see findTemplateDirsUnder). +/** @type {Set} */ +const BLOCKED_CATEGORY_SEGMENTS = new Set(); + +// De-dupes the per-category "skipped" log line so an excluded category that +// spans many templates is reported once, not once per template. +/** @type {Set} */ +const skippedCategoriesLogged = new Set(); + +/** + * Collected anomaly messages surfaced in CI step summary so reviewers do not + * silently merge a catalog with missing/derived data. Always populated, even + * when running locally without a step-summary file. + * @type {string[]} + */ +const warnings = []; + +/** + * Record a warning that should appear in the CI step summary, and mirror it + * to stderr for live log visibility. + * @param {string} message + */ +function warn(message) { + warnings.push(message); + console.warn(`WARN: ${message}`); +} + +/** + * @param {string} url + * @returns {Promise} + */ +async function fetchJson(url) { + /** @type {Record} */ + const headers = { + 'User-Agent': 'microsoft-foundry-for-vscode-sync', + Accept: 'application/vnd.github+json', + }; + if (GITHUB_TOKEN) { + headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`; + } + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${url}`); + } + return response.json(); +} + +/** + * @param {string} url + * @returns {Promise} + */ +async function fetchText(url) { + /** @type {Record} */ + const headers = { 'User-Agent': 'microsoft-foundry-for-vscode-sync' }; + if (GITHUB_TOKEN) { + headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`; + } + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${url}`); + } + return response.text(); +} + +/** + * Validate that a path segment is safe: no traversal, no special chars, no + * leading-dot directories (e.g. `.claude`, `.github`) which are tooling + * metadata, not sample templates. + * @param {string} segment + * @returns {boolean} + */ +function isSafePathSegment(segment) { + if (segment === '.' || segment === '..' || segment.startsWith('.')) { + return false; + } + return SAFE_PATH_SEGMENT.test(segment); +} + +/** + * Parse the commit SHA from the CLI argument. + * @returns {string} + */ +function parseCommitShaArg() { + const sha = process.argv[2] || ''; + if (!sha) { + console.error('Usage: node generate_sample_catalog.mjs '); + process.exit(1); + } + if (!/^[0-9a-f]{7,40}$/i.test(sha)) { + throw new Error(`Invalid commit SHA: "${sha}"`); + } + return sha; +} + +/** + * Fetch the full recursive git tree for a ref. One API call covers the entire + * `samples/` hierarchy, which is much cheaper (and more accurate) than walking + * the `/contents/` endpoint level-by-level — and it lets us discover samples + * at arbitrary nesting depths (e.g. `bring-your-own/voicelive/