diff --git a/.github/scripts/add-code-samples.ts b/.github/scripts/add-code-samples.ts new file mode 100644 index 00000000..0aaaa7c4 --- /dev/null +++ b/.github/scripts/add-code-samples.ts @@ -0,0 +1,541 @@ +#!/usr/bin/env ts-node + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import simpleGit from 'simple-git'; +import { findMatchingFiles } from './utils'; + +// SDK Repository configurations in format: REPO_URL#BRANCH#DIRECTORY +// Repository names use language codes as prefixes (e.g., CURL_, DOTNET_, etc.) +const SDK_REPOSITORIES = { + CURL_SAMPLES_REPO: 'https://github.com/box-community/box-curl-samples.git#main#/', + DOTNET_SAMPLES_REPO: 'https://github.com/box/box-windows-sdk-v2.git#main#/docs/', + SWIFT_SAMPLES_REPO: 'https://github.com/box/box-ios-sdk.git#main#/docs/', + JAVA_SAMPLES_REPO: 'https://github.com/box/box-java-sdk.git#main#/docs/', + NODE_SAMPLES_REPO: 'https://github.com/box/box-node-sdk.git#main#/docs/', + PYTHON_SAMPLES_REPO: 'https://github.com/box/box-python-sdk.git#main#/docs/', +}; + +interface CodeSample { + lang: string; + label: string; + source: string; +} + +interface SampleData { + operationId: string; + lang: string; + code: string; +} + +interface RepoConfig { + url: string; + branch: string; + directory: string; + language: string; +} + +/** + * Parse repository configuration string + * Format: REPO_URL#BRANCH#DIRECTORY + */ +function parseRepoConfig(configString: string, repoName: string): RepoConfig { + const parts = configString.split('#'); + + if (parts.length !== 3) { + throw new Error(`Invalid repo config format: ${configString}`); + } + + const [url, branch, directory] = parts; + + // Extract language from repo name (e.g., CURL_SAMPLES_REPO -> curl) + const languagePrefix = repoName.split('_')[0]; + const language = languagePrefix.toLowerCase(); + + return { + url, + branch, + directory, + language, + }; +} + +/** + * Clone a repository with specified branch using sparse checkout for specific directory + */ +async function cloneRepository(config: RepoConfig, cloneDir: string): Promise { + console.log(`๐Ÿ“ฆ Cloning ${config.language} repository (${config.directory}) to ${cloneDir}...`); + + try { + // Create directory if it doesn't exist + if (!fs.existsSync(cloneDir)) { + fs.mkdirSync(cloneDir, { recursive: true }); + } + + const git = simpleGit(cloneDir); + + // Initialize empty repository + await git.init(); + + // Add remote + await git.addRemote('origin', config.url); + + // Enable sparse checkout + await git.raw(['config', 'core.sparseCheckout', 'true']); + + // Configure sparse checkout to only include the target directory + // Remove leading/trailing slashes and handle root directory + const sparseCheckoutPath = config.directory === '/' || config.directory === '' + ? '*' + : config.directory.replace(/^\/+|\/+$/g, '') + '/*'; + + const sparseCheckoutFile = path.join(cloneDir, '.git', 'info', 'sparse-checkout'); + const sparseCheckoutDir = path.dirname(sparseCheckoutFile); + + if (!fs.existsSync(sparseCheckoutDir)) { + fs.mkdirSync(sparseCheckoutDir, { recursive: true }); + } + + fs.writeFileSync(sparseCheckoutFile, sparseCheckoutPath); + + // Pull only the specified directory + await git.raw(['pull', '--depth', '1', 'origin', config.branch]); + + console.log(`โœ… Repository cloned successfully (sparse checkout: ${sparseCheckoutPath})`); + return cloneDir; + } catch (error) { + console.error(`โŒ Error cloning repository:`, error); + throw error; + } +} + +/** + * Extract samples from markdown files in the docs directory + */ +function extractSamplesFromMarkdown(docsDir: string, language: string): SampleData[] { + console.log(`๐Ÿ“– Extracting ${language} samples from ${docsDir}...`); + const samples: SampleData[] = []; + + if (!fs.existsSync(docsDir)) { + console.warn(`โš ๏ธ Docs directory not found: ${docsDir}`); + return samples; + } + + const markdownFiles = findMarkdownFiles(docsDir); + console.log(` Found ${markdownFiles.length} markdown files`); + + for (const filePath of markdownFiles) { + const content = fs.readFileSync(filePath, 'utf-8'); + const extractedSamples = parseSamplesFromContent(content, language); + samples.push(...extractedSamples); + } + + console.log(`โœ… Extracted ${samples.length} samples`); + return samples; +} + +/** + * Recursively find all markdown files in a directory + */ +function findMarkdownFiles(dir: string): string[] { + const files: string[] = []; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...findMarkdownFiles(fullPath)); + } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.markdown'))) { + files.push(fullPath); + } + } + + return files; +} + +/** + * Parse samples from markdown content + * Looks for pattern: or + * Followed by a code block (with flexible whitespace) + * Converts space-separated identifiers to hash format (e.g., "put_files_id add_shared_link" -> "put_files_id#add_shared_link") + */ +function parseSamplesFromContent(content: string, lang: string): SampleData[] { + const samples: SampleData[] = []; + + // Match followed by code block + // Allows flexible whitespace between comment and code block + // Captures optional second identifier (variant) after space + // Allows dots in operationId for version suffixes (e.g., v2025.0) + // Allows any non-whitespace characters in language identifier (e.g., dotnet, c#, c++, objective-c) + const samplePattern = /\s*```\S*\s*\n([\s\S]*?)```/g; + + let match; + while ((match = samplePattern.exec(content)) !== null) { + const baseOperationId = match[1]; + const variant = match[2]; // Optional variant identifier + const code = match[3].trim(); + + // Combine with hash if variant exists (e.g., put_files_id#add_shared_link) + const operationId = variant ? `${baseOperationId}#${variant}` : baseOperationId; + + samples.push({ operationId, lang, code }); + } + + return samples; +} + +/** + * Load OpenAPI schemas from files + */ +function loadOpenAPISchemas(filePaths: string[]): Array<{ path: string; schema: any }> { + console.log(`๐Ÿ“„ Loading ${filePaths.length} OpenAPI schema(s)...`); + + const schemas: Array<{ path: string; schema: any }> = []; + + for (const filePath of filePaths) { + const fullPath = path.resolve(process.cwd(), filePath); + + if (!fs.existsSync(fullPath)) { + console.warn(`โš ๏ธ OpenAPI file not found: ${filePath} - skipping`); + continue; + } + + const content = fs.readFileSync(fullPath, 'utf-8'); + const schema = JSON.parse(content); + schemas.push({ path: fullPath, schema }); + console.log(` โœ“ Loaded ${filePath}`); + } + + if (schemas.length === 0) { + throw new Error('No OpenAPI files could be loaded'); + } + + console.log(`โœ… Loaded ${schemas.length} OpenAPI schema(s)`); + return schemas; +} + +/** + * Merge extracted samples into OpenAPI schema + * Only one sample per language per operation - replaces existing samples with same language + */ +function mergeSamplesIntoSchema( + schema: any, + samplesArray: SampleData[] +): { matched: number; added: number; matchedIds: string[] } { + console.log(`๐Ÿ”— Merging samples into OpenAPI schema...`); + + let matchedCount = 0; + let addedCount = 0; + const matchedIds: string[] = []; + + // Group samples by operationId, then by language (only keep last sample per language) + const samplesByOperation = new Map>(); + for (const sample of samplesArray) { + if (!samplesByOperation.has(sample.operationId)) { + samplesByOperation.set(sample.operationId, new Map()); + } + // This will replace any existing sample with the same language + samplesByOperation.get(sample.operationId)!.set(sample.lang, sample); + } + + // Iterate through all paths and operations + const paths = schema.paths || {}; + + for (const [pathName, pathItem] of Object.entries(paths)) { + const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']; + + for (const method of methods) { + const operation = (pathItem as any)[method]; + + if (!operation || !operation.operationId) { + continue; + } + + const operationId = operation.operationId; + + // Check if we have samples for this operation + if (samplesByOperation.has(operationId)) { + const samplesByLang = samplesByOperation.get(operationId)!; + matchedCount++; + matchedIds.push(operationId); + + const label = operation.summary || operationId; + + // Initialize x-codeSamples array if it doesn't exist + if (!operation['x-codeSamples']) { + operation['x-codeSamples'] = []; + } + + // Create a map of existing samples by language + const existingSamplesByLang = new Map(); + for (let i = 0; i < operation['x-codeSamples'].length; i++) { + const existingSample = operation['x-codeSamples'][i]; + if (existingSample.lang) { + existingSamplesByLang.set(existingSample.lang, i); + } + } + + // Add or replace samples for each language + for (const [lang, sample] of samplesByLang.entries()) { + const newSample: CodeSample = { + lang: lang, + label: label, + source: sample.code + }; + + if (existingSamplesByLang.has(lang)) { + // Replace existing sample with same language + const index = existingSamplesByLang.get(lang)!; + operation['x-codeSamples'][index] = newSample; + console.warn(` โš ๏ธ Replaced existing ${lang} sample for ${operationId}`); + } else { + // Add new sample + operation['x-codeSamples'].push(newSample); + } + addedCount++; + } + + // console.log(` โœ“ Processed ${samplesByLang.size} sample(s) for ${operationId} (${method.toUpperCase()} ${pathName})`); + } + } + } + + console.log(`โœ… Matched ${matchedCount} operations, processed ${addedCount} samples`); + return { matched: matchedCount, added: addedCount, matchedIds }; +} + +/** + * Write updated OpenAPI schemas back to files + */ +function writeOpenAPISchemas(schemas: Array<{ path: string; schema: any }>): void { + console.log(`๐Ÿ’พ Writing ${schemas.length} updated schema(s)...`); + + for (const schemaInfo of schemas) { + const content = JSON.stringify(schemaInfo.schema, null, 2); + fs.writeFileSync(schemaInfo.path, content, 'utf-8'); + console.log(` โœ“ Updated ${path.basename(schemaInfo.path)}`); + } + + console.log('โœ… All schemas updated successfully'); +} + +/** + * Clean up cloned repository + */ +function cleanup(dir: string): void { + console.log(`๐Ÿงน Cleaning up ${dir}...`); + + try { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + console.log('โœ… Cleanup complete'); + } + } catch (error) { + console.warn(`โš ๏ธ Error during cleanup: ${error}`); + } +} + +/** + * Main execution function + */ +export async function main(directoryPath?: string, pattern?: string): Promise { + // Get command line arguments if not provided + let dir = directoryPath; + let pat = pattern; + + if (!dir || !pat) { + const args = process.argv.slice(2); + + if (args.length < 2) { + console.error('โŒ Error: Missing required arguments'); + console.error(''); + console.error('Usage: cd .github/scripts && npm run add-sdk-samples -- '); + console.error(''); + console.error('Arguments:'); + console.error(' - Path to directory containing OpenAPI JSON files'); + console.error(' - Regex pattern to match filenames'); + console.error(''); + console.error('Examples:'); + console.error(' cd .github/scripts && npm run add-sdk-samples -- "../../openapi" "openapi.*\\.json"'); + return 1; + } + + dir = args[0]; + pat = args[1]; + } + + console.log('๐Ÿš€ Starting multi-SDK sample extraction...\n'); + console.log(`Searching in directory: ${dir}`); + console.log(`Pattern: ${pat}\n`); + + // Find matching OpenAPI files + const openapiFiles = findMatchingFiles(dir, pat); + + if (openapiFiles.length === 0) { + console.log('โš ๏ธ No files found matching the pattern'); + return 1; + } + + console.log(`Found ${openapiFiles.length} matching file(s):\n`); + openapiFiles.forEach(file => console.log(` - ${file}`)); + console.log(''); + + const startTime = Date.now(); + const cloneDirs: string[] = []; + const allSamples: SampleData[] = []; + + try { + // Step 1: Extract samples from all SDK repositories + for (const [repoName, configString] of Object.entries(SDK_REPOSITORIES)) { + console.log(`\n${'='.repeat(60)}`); + console.log(`Processing ${repoName}`); + console.log('='.repeat(60)); + + try { + // Parse repository configuration + const config = parseRepoConfig(configString, repoName); + console.log(` Language: ${config.language}`); + console.log(` Branch: ${config.branch}`); + console.log(` Directory: ${config.directory}`); + console.log(); + + // Clone repository + const cloneDir = path.join(os.tmpdir(), `box-sdk-${config.language}-${Date.now()}`); + cloneDirs.push(cloneDir); + await cloneRepository(config, cloneDir); + console.log(); + + // Extract samples from markdown files + const docsDir = path.join(cloneDir, config.directory); + const samples = extractSamplesFromMarkdown(docsDir, config.language); + allSamples.push(...samples); + console.log(); + } catch (error) { + console.error(`โš ๏ธ Error processing ${repoName}:`, error); + console.log(' Continuing with next repository...\n'); + } + } + + console.log(`${'='.repeat(60)}\n`); + + if (allSamples.length === 0) { + console.warn('โš ๏ธ No samples found in any documentation'); + return 1; + } + + + // Step 2: Load OpenAPI schemas + const schemas = loadOpenAPISchemas(openapiFiles); + console.log(); + + // Step 3: Merge all samples into each schema + let totalMatched = 0; + let totalAdded = 0; + const matchedOperationIds = new Set(); + const allSchemaOperationIds = new Set(); + + for (const schemaInfo of schemas) { + console.log(`๐Ÿ”— Processing ${path.basename(schemaInfo.path)}...`); + + // Count operations in this schema and collect all operation IDs + const schemaPaths = schemaInfo.schema.paths || {}; + let operationCount = 0; + for (const pathItem of Object.values(schemaPaths)) { + const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']; + for (const method of methods) { + const opId = (pathItem as any)[method]?.operationId; + if (opId) { + operationCount++; + allSchemaOperationIds.add(opId); + } + } + } + console.log(` Total operations in schema: ${operationCount}`); + + const { matched, added, matchedIds } = mergeSamplesIntoSchema(schemaInfo.schema, allSamples); + totalMatched += matched; + totalAdded += added; + + // Track which operationIds were matched + matchedIds.forEach(id => matchedOperationIds.add(id)); + + console.log(); + } + + // Report unmatched samples + const uniqueSampleOperationIds = new Set(allSamples.map(s => s.operationId)); + const unmatchedSamples = Array.from(uniqueSampleOperationIds).filter(id => !matchedOperationIds.has(id)); + + if (unmatchedSamples.length > 0) { + console.log(`โš ๏ธ Warning: ${unmatchedSamples.length} sample(s) did not match any operation:`); + unmatchedSamples.forEach(id => console.log(` - ${id}`)); + console.log(); + } + + // Report operations without samples + const operationsWithoutSamples = Array.from(allSchemaOperationIds).filter(id => !matchedOperationIds.has(id)); + + if (operationsWithoutSamples.length > 0) { + console.log(`โš ๏ธ Warning: ${operationsWithoutSamples.length} operation(s) did not receive any samples:`); + operationsWithoutSamples.forEach(id => console.log(` - ${id}`)); + console.log(); + } + + if (totalAdded === 0) { + console.warn('โš ๏ธ No samples were added to any schema'); + return 1; + } + + // Step 4: Write updated schemas + writeOpenAPISchemas(schemas); + console.log(); + + // Summary + const duration = ((Date.now() - startTime) / 1000).toFixed(2); + + // Count samples by language + const samplesByLang = new Map(); + for (const sample of allSamples) { + samplesByLang.set(sample.lang, (samplesByLang.get(sample.lang) || 0) + 1); + } + + console.log('๐Ÿ“Š Summary:'); + console.log(` โ€ข OpenAPI schemas processed: ${schemas.length}`); + console.log(` โ€ข Total samples extracted: ${allSamples.length}`); + console.log(` โ€ข Samples by language:`); + for (const [lang, count] of Array.from(samplesByLang.entries()).sort()) { + console.log(` - ${lang}: ${count}`); + } + console.log(` โ€ข Operations matched: ${totalMatched}`); + console.log(` โ€ข Samples processed: ${totalAdded}`); + console.log(` โ€ข Duration: ${duration}s`); + console.log('\nโœจ Done!'); + + return 0; + } catch (error) { + console.error('\nโŒ Error:', error); + return 1; + } finally { + // Step 5: Cleanup all cloned directories + if (cloneDirs.length > 0) { + console.log(); + for (const dir of cloneDirs) { + cleanup(dir); + } + } + } +} + +// Run if executed directly +if (require.main === module) { + main() + .then((exitCode) => process.exit(exitCode)) + .catch((error) => { + console.error('Fatal error:', error); + process.exit(1); + }); +} + diff --git a/.github/scripts/package-lock.json b/.github/scripts/package-lock.json new file mode 100644 index 00000000..71de84a6 --- /dev/null +++ b/.github/scripts/package-lock.json @@ -0,0 +1,294 @@ +{ + "name": "box-openapi-scripts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "box-openapi-scripts", + "version": "1.0.0", + "devDependencies": { + "@types/node": "20.10.0", + "simple-git": "3.21.0", + "ts-node": "10.9.2", + "typescript": "5.3.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", + "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/simple-git": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.21.0.tgz", + "integrity": "sha512-oTzw9248AF5bDTMk9MrxsRzEzivMlY+DWH0yWS4VYpMhNLhDWnN06pCtaUyPnqv/FpsdeNmRqmZugMABHRPdDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/.github/scripts/package.json b/.github/scripts/package.json new file mode 100644 index 00000000..61db89f2 --- /dev/null +++ b/.github/scripts/package.json @@ -0,0 +1,16 @@ +{ + "name": "box-openapi-scripts", + "version": "1.0.0", + "description": "Scripts for Box OpenAPI specification", + "scripts": { + "replace-links": "ts-node replace-links.ts", + "add-code-samples": "ts-node add-code-samples.ts" + }, + "devDependencies": { + "simple-git": "3.21.0", + "@types/node": "20.10.0", + "ts-node": "10.9.2", + "typescript": "5.3.3" + } +} + diff --git a/.github/scripts/replace-links.ts b/.github/scripts/replace-links.ts new file mode 100644 index 00000000..5f6370de --- /dev/null +++ b/.github/scripts/replace-links.ts @@ -0,0 +1,119 @@ +#!/usr/bin/env ts-node + +import * as fs from 'fs'; +import * as path from 'path'; +import { findMatchingFiles } from './utils'; + +/** + * Replace all occurrences of provided links with localised version in the given content + */ +function replaceLinks(content: string, oldUrl: string, newUrl: string): string { + return content.replace(new RegExp(oldUrl, 'g'), newUrl); +} + +/** + * Process a single OpenAPI file + */ +function processFile(filePath: string, oldUrl: string, newUrl: string): void { + try { + console.log(`Processing: ${filePath}`); + + // Check if file exists + if (!fs.existsSync(filePath)) { + console.error(` โŒ Error: File not found: ${filePath}`); + process.exit(1); + } + + // Read the file + const content = fs.readFileSync(filePath, 'utf-8'); + + // Verify it's valid JSON + try { + JSON.parse(content); + } catch (e) { + console.error(` โŒ Error: Invalid JSON in file: ${filePath}`); + console.error(` ${(e as Error).message}`); + process.exit(1); + } + + // Count occurrences before replacement + const matches = content.match(new RegExp(oldUrl, 'g')); + const count = matches ? matches.length : 0; + + if (count === 0) { + console.log(` โ„น๏ธ No links to replace in ${filePath}`); + return; + } + + // Replace links + const updatedContent = replaceLinks(content, oldUrl, newUrl); + + // Write back to file + fs.writeFileSync(filePath, updatedContent, 'utf-8'); + + console.log(` โœ… Replaced ${count} occurrence(s) in ${filePath}`); + } catch (e) { + console.error(` โŒ Error processing ${filePath}:`, (e as Error).message); + process.exit(1); + } +} + +/** + * Main execution + */ +export function main(): number { + // Get command line arguments + const args = process.argv.slice(2); + + if (args.length < 4) { + console.error('โŒ Error: Missing required arguments'); + console.error(''); + console.error('Usage: cd .github/scripts && npm run replace-links -- '); + console.error(''); + console.error('Arguments:'); + console.error(' - Path to directory containing JSON files'); + console.error(' - Regex pattern to match filenames'); + console.error(' - URL to replace'); + console.error(' - Replacement URL'); + console.error(''); + console.error('Examples:'); + console.error(' cd .github/scripts && npm run replace-links -- "openapi" "openapi.*\\.json" "https://developer.box.com" "https://ja.developer.box.com"'); + return 1; + } + + const directoryPath = args[0]; + const pattern = args[1]; + const oldUrl = args[2]; + const newUrl = args[3]; + + console.log(`Replacing "${oldUrl}" with "${newUrl}"`); + console.log(`Searching in directory: ${directoryPath}`); + console.log(`Pattern: ${pattern}\n`); + + // Find matching files + const filePaths = findMatchingFiles(directoryPath, pattern); + + if (filePaths.length === 0) { + console.log('โš ๏ธ No files found matching the pattern'); + return 0; + } + + console.log(`Found ${filePaths.length} matching file(s):\n`); + filePaths.forEach(file => console.log(` - ${file}`)); + console.log(''); + + // Process each file + for (const filePath of filePaths) { + processFile(filePath, oldUrl, newUrl); + } + + console.log('\nโœ… All files processed successfully!'); + return 0; +} + +// Run if executed directly +if (require.main === module) { + const exitCode = main(); + process.exit(exitCode); +} + diff --git a/.github/scripts/tsconfig.json b/.github/scripts/tsconfig.json new file mode 100644 index 00000000..ab37c49a --- /dev/null +++ b/.github/scripts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["./**/*"], + "exclude": ["node_modules"] +} diff --git a/.github/scripts/utils.ts b/.github/scripts/utils.ts new file mode 100644 index 00000000..5509bea5 --- /dev/null +++ b/.github/scripts/utils.ts @@ -0,0 +1,46 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Find all files in a directory that match the given regex pattern + */ +export function findMatchingFiles(directoryPath: string, pattern: string): string[] { + try { + // Check if directory exists + if (!fs.existsSync(directoryPath)) { + console.error(`โŒ Error: Directory not found: ${directoryPath}`); + process.exit(1); + } + + // Check if it's a directory + const stats = fs.statSync(directoryPath); + if (!stats.isDirectory()) { + console.error(`โŒ Error: Path is not a directory: ${directoryPath}`); + process.exit(1); + } + + // Read all files in the directory + const files = fs.readdirSync(directoryPath); + + // Create regex from pattern + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch (e) { + console.error(`โŒ Error: Invalid regex pattern: ${pattern}`); + console.error(` ${(e as Error).message}`); + process.exit(1); + } + + // Filter files that match the pattern + const matchingFiles = files + .filter(file => regex.test(file)) + .map(file => path.join(directoryPath, file)); + + return matchingFiles; + } catch (e) { + console.error(`โŒ Error reading directory ${directoryPath}:`, (e as Error).message); + process.exit(1); + } +} + diff --git a/.github/workflows/en-mint.yml b/.github/workflows/en-mint.yml new file mode 100644 index 00000000..a3761a66 --- /dev/null +++ b/.github/workflows/en-mint.yml @@ -0,0 +1,65 @@ +# The name of this GH action +name: Process EN Branch + +# Defines when this action should be run +on: + # Run on any Push + push: + branches: + - en + # Allow manual triggering + workflow_dispatch: + +jobs: + # A task that processes OpenAPI files from the en branch + process-en-branch: + # We run this on the latest ubuntu + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + matrix: + node-version: [24.x] + + steps: + - name: Check out the en branch + uses: actions/checkout@v4 + with: + ref: en + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Check out scripts from main branch + uses: actions/checkout@v4 + with: + ref: main + sparse-checkout: | + .github/scripts + sparse-checkout-cone-mode: false + path: .main-scripts + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install script dependencies + working-directory: .main-scripts/.github/scripts + run: npm install + + - name: Run add-code-samples script + working-directory: .main-scripts/.github/scripts + run: npm run add-code-samples -- "../../.." "openapi.*\\.json" + + - name: Cleanup working directory + run: rm -rf .main-scripts + + - name: Push processed files to en-mint branch + uses: s0/git-publish-subdir-action@v2.6.0 + env: + REPO: self + BRANCH: en-mint + FOLDER: . + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MESSAGE: "Add SDK code samples to OpenAPI files" diff --git a/.github/workflows/jp-mint.yml b/.github/workflows/jp-mint.yml new file mode 100644 index 00000000..cfbf7251 --- /dev/null +++ b/.github/workflows/jp-mint.yml @@ -0,0 +1,70 @@ +# The name of this GH action +name: Process JP Branch + +# Defines when this action should be run +on: + # Run on any Push + push: + branches: + - jp + # Allow manual triggering + workflow_dispatch: + +jobs: + # A task that processes OpenAPI files from the jp branch + process-jp-branch: + # We run this on the latest ubuntu + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + matrix: + node-version: [24.x] + + steps: + - name: Check out the jp branch + uses: actions/checkout@v4 + with: + ref: jp + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Check out scripts from main branch + uses: actions/checkout@v4 + with: + ref: main + sparse-checkout: | + .github/scripts + sparse-checkout-cone-mode: false + path: .main-scripts + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install script dependencies + working-directory: .main-scripts/.github/scripts + run: npm install + + - name: Run add-code-samples script + working-directory: .main-scripts/.github/scripts + run: npm run add-code-samples -- "../../.." "openapi.*\\.json" + + - name: Run link replacement script + working-directory: .main-scripts/.github/scripts + run: npm run replace-links -- "../../.." "openapi.*\\.json" "https://developer.box.com/" "https://developer.box.com/ja/" + + - name: Cleanup working directory + run: rm -rf .main-scripts + + - name: Push processed files to jp-mint branch + uses: s0/git-publish-subdir-action@v2.6.0 + env: + REPO: self + BRANCH: jp-mint + FOLDER: . + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MESSAGE: "Add SDK code samples and replace links for Japanese locale" + diff --git a/.github/workflows/notify.yml b/.github/workflows/notify.yml index e82ce582..fe544c16 100644 --- a/.github/workflows/notify.yml +++ b/.github/workflows/notify.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - node-version: [14.x] + node-version: [24.x] steps: - name: "Trigger Netlify deployment" @@ -51,4 +51,4 @@ jobs: SLACK_USERNAME: GitHub Actions SLACK_AVATAR: "https://avatars3.githubusercontent.com/u/8659759?s=200&v=4" with: - args: "Error notifying for `jp` job in OpenAPI CI" + args: "Error notifying for `jp` job in OpenAPI CI" \ No newline at end of file diff --git a/.gitignore b/.gitignore index ee124d8b..6d97b72a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ .idea #OSX -.DS_Store \ No newline at end of file +.DS_Store + +.github/scripts/node_modules \ No newline at end of file