From 11c5ed0d4cee2b26144a307fd60f2859315aded5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:59:47 +0000 Subject: [PATCH 01/18] Initial plan From 49f17614681b79f00cba9b978348ed942f4bd387 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:10:00 +0000 Subject: [PATCH 02/18] Sync lease file generation script with ARM lease validation checks Co-authored-by: tejaswiMinnu <14865963+tejaswiMinnu@users.noreply.github.com> --- .gitignore | 1 + .../Tests/fetch-resource-providers.test.js | 99 ++++ .../Tests/generate-lease-files.test.js | 310 +++++++++++ eng/scripts/fetch-resource-providers.js | 127 +++++ eng/scripts/generate-lease-files.js | 480 ++++++++++++++++++ 5 files changed, 1017 insertions(+) create mode 100644 eng/scripts/Tests/fetch-resource-providers.test.js create mode 100644 eng/scripts/Tests/generate-lease-files.test.js create mode 100644 eng/scripts/fetch-resource-providers.js create mode 100644 eng/scripts/generate-lease-files.js diff --git a/.gitignore b/.gitignore index 43bcc525987a..aa42a060cf3f 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,7 @@ warnings.txt # Blanket ignores *.js !.github/**/*.js +!eng/scripts/**/*.js *.d.ts *.js.map *.d.ts.map diff --git a/eng/scripts/Tests/fetch-resource-providers.test.js b/eng/scripts/Tests/fetch-resource-providers.test.js new file mode 100644 index 000000000000..3fb8db3a5487 --- /dev/null +++ b/eng/scripts/Tests/fetch-resource-providers.test.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// Tests for fetch-resource-providers.js + +const path = require('path'); +const { findRepoRoot, findResourceProviders, formatOutput } = require('../fetch-resource-providers.js'); + +function assert(condition, message) { + if (!condition) throw new Error(`Test failed: ${message}`); +} + +function testFindRepoRoot() { + const repoRoot = findRepoRoot(); + assert(repoRoot.endsWith('azure-rest-api-specs'), 'Repo root should end with azure-rest-api-specs'); + assert(require('fs').existsSync(path.join(repoRoot, 'specification')), 'Specification directory should exist'); + console.log('✓ findRepoRoot test passed'); +} + +function testFindResourceProvidersWithout() { + const repoRoot = findRepoRoot(); + const rps = findResourceProviders(repoRoot, false); + assert(rps.length > 0, 'Should find resource providers without service groups'); + assert(rps.every(rp => rp.name && rp.service && rp.path), 'All RPs should have name, service, and path'); + assert(rps.every(rp => !rp.service_groups), 'RPs without service groups should not have service_groups field'); + assert(rps.some(rp => rp.name === 'Microsoft.Storage'), 'Should include Microsoft.Storage'); + assert(!rps.some(rp => rp.name === 'Microsoft.Compute'), 'Should not include Microsoft.Compute (has service groups)'); + console.log(`✓ findResourceProviders (without) test passed - found ${rps.length} RPs`); +} + +function testFindResourceProvidersWith() { + const repoRoot = findRepoRoot(); + const rps = findResourceProviders(repoRoot, true); + assert(rps.length > 0, 'Should find resource providers with service groups'); + assert(rps.every(rp => rp.name && rp.service && rp.path && rp.service_groups), 'All RPs should have name, service, path, and service_groups'); + assert(rps.every(rp => Array.isArray(rp.service_groups)), 'service_groups should be an array'); + const compute = rps.find(rp => rp.name === 'Microsoft.Compute'); + assert(compute, 'Should include Microsoft.Compute'); + assert(compute.service === 'compute', 'Microsoft.Compute service should be "compute"'); + assert(compute.service_groups.includes('Compute'), 'Microsoft.Compute should have Compute service group'); + assert(!rps.some(rp => rp.name === 'Microsoft.Storage'), 'Should not include Microsoft.Storage (no service groups)'); + console.log(`✓ findResourceProviders (with) test passed - found ${rps.length} RPs`); +} + +function testFormatOutputList() { + const rps = [ + { name: 'Microsoft.Test', service: 'test', path: 'test/path' }, + { name: 'Microsoft.Test2', service: 'test2', path: 'test2/path', service_groups: ['Group1', 'Group2'] } + ]; + + const outputWithout = formatOutput([rps[0]], 'list', false); + assert(outputWithout.includes('test, Microsoft.Test'), 'List format should include service and name'); + + const outputWith = formatOutput([rps[1]], 'list', true); + assert(outputWith.includes('test2, Microsoft.Test2, [Group1, Group2]'), 'List format with groups should include service, name, and groups'); + + console.log('✓ formatOutput (list) test passed'); +} + +function testFormatOutputJson() { + const rps = [{ name: 'Microsoft.Test', service: 'test', path: 'test/path' }]; + const output = formatOutput(rps, 'json', false); + const parsed = JSON.parse(output); + assert(Array.isArray(parsed), 'JSON output should be an array'); + assert(parsed[0].name === 'Microsoft.Test', 'JSON should preserve RP name'); + console.log('✓ formatOutput (json) test passed'); +} + +function testFormatOutputTable() { + const rps = [{ name: 'Microsoft.Test', service: 'test', path: 'test/path' }]; + const output = formatOutput(rps, 'table', false); + assert(output.includes('Service'), 'Table should have Service header'); + assert(output.includes('Resource Provider'), 'Table should have Resource Provider header'); + assert(output.includes('test'), 'Table should include service name'); + assert(output.includes('Microsoft.Test'), 'Table should include RP name'); + console.log('✓ formatOutput (table) test passed'); +} + +function runTests() { + console.log('Running tests for fetch-resource-providers.js...\n'); + + try { + testFindRepoRoot(); + testFindResourceProvidersWithout(); + testFindResourceProvidersWith(); + testFormatOutputList(); + testFormatOutputJson(); + testFormatOutputTable(); + + console.log('\n✅ All tests passed!'); + return 0; + } catch (error) { + console.error(`\n❌ ${error.message}`); + console.error(error.stack); + return 1; + } +} + +if (require.main === module) { + process.exit(runTests()); +} diff --git a/eng/scripts/Tests/generate-lease-files.test.js b/eng/scripts/Tests/generate-lease-files.test.js new file mode 100644 index 000000000000..41481da32e86 --- /dev/null +++ b/eng/scripts/Tests/generate-lease-files.test.js @@ -0,0 +1,310 @@ +#!/usr/bin/env node +// Tests for generate-lease-files.js + +const path = require('path'); +const fs = require('fs'); +const { + parseInputLine, + generateLeaseYaml, + getLeasePath, + validateStartDate, + validateDuration, + validateResourceProvider, + validateServiceName, + validateReviewer, + getTodayDate, +} = require('../generate-lease-files.js'); + +function assert(condition, message) { + if (!condition) throw new Error(`Test failed: ${message}`); +} + +function testParseInputLine() { + // Test without service groups + const result1 = parseInputLine('storage, Microsoft.Storage'); + assert(result1.service === 'storage', 'Service should be storage'); + assert(result1.resourceProvider === 'Microsoft.Storage', 'RP should be Microsoft.Storage'); + assert(result1.serviceGroups.length === 0, 'Should have no service groups'); + + // Test with service groups + const result2 = parseInputLine('compute, Microsoft.Compute, [ComputeRP, DiskRP]'); + assert(result2.service === 'compute', 'Service should be compute'); + assert(result2.resourceProvider === 'Microsoft.Compute', 'RP should be Microsoft.Compute'); + assert(result2.serviceGroups.length === 2, 'Should have 2 service groups'); + assert(result2.serviceGroups[0] === 'ComputeRP', 'First group should be ComputeRP'); + assert(result2.serviceGroups[1] === 'DiskRP', 'Second group should be DiskRP'); + + // Test empty line + const result3 = parseInputLine(''); + assert(result3 === null, 'Empty line should return null'); + + // Test comment + const result4 = parseInputLine('# This is a comment'); + assert(result4 === null, 'Comment should return null'); + + console.log('✓ parseInputLine tests passed'); +} + +function testGenerateLeaseYaml() { + const yaml = generateLeaseYaml('Microsoft.Test', '2026-06-01', 'P180D', '@johnDoe'); + + assert(yaml.includes('resource-provider: Microsoft.Test'), 'Should include resource provider'); + assert(yaml.includes('startdate: 2026-06-01'), 'Should include startdate'); + assert(yaml.includes('duration: P180D'), 'Should include duration (not duration-days)'); + assert(!yaml.includes('duration-days:'), 'Should NOT use duration-days field'); + assert(yaml.includes('reviewer: @johnDoe'), 'Should include reviewer'); + + console.log('✓ generateLeaseYaml tests passed'); +} + +function testGetLeasePath() { + const repoRoot = '/repo'; + + // Without service group + const path1 = getLeasePath(repoRoot, 'storage', 'Microsoft.Storage'); + assert(path1 === '/repo/.github/arm-leases/storage/Microsoft.Storage/lease.yaml', + 'Path without service group should be correct'); + + // With service group + const path2 = getLeasePath(repoRoot, 'compute', 'Microsoft.Compute', 'DiskRP'); + assert(path2 === '/repo/.github/arm-leases/compute/Microsoft.Compute/DiskRP/lease.yaml', + 'Path with service group should be correct'); + + console.log('✓ getLeasePath tests passed'); +} + +function testValidateStartDate() { + const today = getTodayDate(); + + // Valid date (today) + try { + validateStartDate(today); + console.log(' ✓ Today date is valid'); + } catch (error) { + throw new Error('Today should be valid'); + } + + // Valid date (future) + try { + validateStartDate('2027-12-31'); + console.log(' ✓ Future date is valid'); + } catch (error) { + throw new Error('Future date should be valid'); + } + + // Valid date (within grace period - 5 days ago) + const fiveDaysAgo = new Date(); + fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5); + const fiveDaysAgoStr = fiveDaysAgo.toISOString().split('T')[0]; + try { + validateStartDate(fiveDaysAgoStr); + console.log(' ✓ Date within 10-day grace period is valid'); + } catch (error) { + throw new Error(`Date within 10-day grace period should be valid: ${error.message}`); + } + + // Invalid format + try { + validateStartDate('12/31/2026'); + throw new Error('Should reject invalid format'); + } catch (error) { + if (error.message.includes('Invalid date format')) { + console.log(' ✓ Rejects invalid date format'); + } else { + throw error; + } + } + + // Past date (beyond grace period) + try { + validateStartDate('2020-01-01'); + throw new Error('Should reject date too far in the past'); + } catch (error) { + if (error.message.includes('past')) { + console.log(' ✓ Rejects date too far in the past'); + } else { + throw error; + } + } + + console.log('✓ validateStartDate tests passed'); +} + +function testValidateDuration() { + // Valid durations + assert(validateDuration('P180D') === 'P180D', 'P180D should be valid'); + assert(validateDuration('P90D') === 'P90D', 'P90D should be valid'); + assert(validateDuration('P1D') === 'P1D', 'P1D should be valid'); + assert(validateDuration('p30d') === 'P30D', 'Should convert to uppercase'); + + // Invalid format + try { + validateDuration('180 days'); + throw new Error('Should reject invalid format'); + } catch (error) { + if (error.message.includes('Invalid duration format')) { + console.log(' ✓ Rejects invalid duration format'); + } else { + throw error; + } + } + + // Over 180 days + try { + validateDuration('P200D'); + throw new Error('Should reject over 180 days'); + } catch (error) { + if (error.message.includes('between 1 and 180')) { + console.log(' ✓ Rejects duration over 180 days'); + } else { + throw error; + } + } + + // Zero days + try { + validateDuration('P0D'); + throw new Error('Should reject 0 days'); + } catch (error) { + if (error.message.includes('between 1 and 180')) { + console.log(' ✓ Rejects zero duration'); + } else { + throw error; + } + } + + console.log('✓ validateDuration tests passed'); +} + +function testValidateResourceProvider() { + // Valid RPs + validateResourceProvider('Microsoft.Test'); + validateResourceProvider('Azure.Widget'); + validateResourceProvider('Contoso.Manager'); + + // Invalid RPs + try { + validateResourceProvider('microsoft.Test'); + throw new Error('Should reject lowercase start'); + } catch (error) { + if (error.message.includes('capital letter')) { + console.log(' ✓ Rejects lowercase start'); + } else { + throw error; + } + } + + console.log('✓ validateResourceProvider tests passed'); +} + +function testValidateServiceName() { + // Valid service names + validateServiceName('storage'); + validateServiceName('compute'); + validateServiceName('testservice123'); + + // Invalid service names + try { + validateServiceName('TestService'); + throw new Error('Should reject uppercase'); + } catch (error) { + if (error.message.includes('lowercase alphanumeric')) { + console.log(' ✓ Rejects uppercase'); + } else { + throw error; + } + } + + try { + validateServiceName('test-service'); + throw new Error('Should reject hyphen'); + } catch (error) { + if (error.message.includes('lowercase alphanumeric')) { + console.log(' ✓ Rejects hyphen'); + } else { + throw error; + } + } + + console.log('✓ validateServiceName tests passed'); +} + +function testValidateReviewer() { + // Valid reviewers (must start with @) + assert(validateReviewer('@githubUser') === '@githubUser', 'Valid @ reviewer should pass'); + assert(validateReviewer('@johnDoe') === '@johnDoe', 'Valid @ reviewer should pass'); + assert(validateReviewer(' @trimmed ') === '@trimmed', 'Should trim whitespace'); + + // Invalid: missing @ prefix + try { + validateReviewer('githubUser'); + throw new Error('Should reject reviewer without @ prefix'); + } catch (error) { + if (error.message.includes('@')) { + console.log(' ✓ Rejects reviewer without @ prefix'); + } else { + throw error; + } + } + + // Invalid: only @ + try { + validateReviewer('@'); + throw new Error('Should reject @ only'); + } catch (error) { + if (error.message.includes('@')) { + console.log(' ✓ Rejects @ only'); + } else { + throw error; + } + } + + // Invalid: empty string + try { + validateReviewer(''); + throw new Error('Should reject empty reviewer'); + } catch (error) { + if (error.message.includes('required')) { + console.log(' ✓ Rejects empty reviewer'); + } else { + throw error; + } + } + + console.log('✓ validateReviewer tests passed'); +} + +function testGetTodayDate() { + const today = getTodayDate(); + assert(/^\d{4}-\d{2}-\d{2}$/.test(today), 'Today should be in YYYY-MM-DD format'); + console.log(`✓ getTodayDate tests passed (today: ${today})`); +} + +function runTests() { + console.log('Running tests for generate-lease-files.js...\n'); + + try { + testParseInputLine(); + testGenerateLeaseYaml(); + testGetLeasePath(); + testValidateStartDate(); + testValidateDuration(); + testValidateResourceProvider(); + testValidateServiceName(); + testValidateReviewer(); + testGetTodayDate(); + + console.log('\n✅ All tests passed!'); + return 0; + } catch (error) { + console.error(`\n❌ ${error.message}`); + console.error(error.stack); + return 1; + } +} + +if (require.main === module) { + process.exit(runTests()); +} + +module.exports = { runTests }; diff --git a/eng/scripts/fetch-resource-providers.js b/eng/scripts/fetch-resource-providers.js new file mode 100644 index 000000000000..c645f6f01473 --- /dev/null +++ b/eng/scripts/fetch-resource-providers.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Fetch Azure resource providers with or without service groups +// Usage: node fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] + +const fs = require('fs'); +const path = require('path'); + +function isServiceGroupDirectory(dirPath) { + const excludeNames = new Set(['stable', 'preview', 'common-types', 'examples']); + try { + return !excludeNames.has(path.basename(dirPath)) && fs.statSync(dirPath).isDirectory(); + } catch { return false; } +} + +function hasVersionDirectories(rpPath) { + return fs.existsSync(path.join(rpPath, 'stable')) || fs.existsSync(path.join(rpPath, 'preview')); +} + +function findRepoRoot(startPath = process.cwd()) { + let current = path.resolve(startPath); + for (let i = 0; i < 6; i++) { + if (fs.existsSync(path.join(current, 'specification'))) return current; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + throw new Error('Could not find repository root. Run from within azure-rest-api-specs.'); +} + +function findResourceProviders(repoRoot, withServiceGroups = false) { + const results = []; + const specDir = path.join(repoRoot, 'specification'); + if (!fs.existsSync(specDir)) throw new Error(`Specification directory not found: ${specDir}`); + + for (const serviceName of fs.readdirSync(specDir)) { + const serviceDir = path.join(specDir, serviceName); + if (!fs.statSync(serviceDir).isDirectory()) continue; + + const rmDir = path.join(serviceDir, 'resource-manager'); + if (!fs.existsSync(rmDir)) continue; + + for (const rpName of fs.readdirSync(rmDir)) { + const rpPath = path.join(rmDir, rpName); + if (!fs.statSync(rpPath).isDirectory() || !rpName.startsWith('Microsoft.')) continue; + + const serviceGroups = fs.readdirSync(rpPath) + .filter(sg => isServiceGroupDirectory(path.join(rpPath, sg))) + .sort(); + + if (withServiceGroups && serviceGroups.length > 0) { + results.push({ + name: rpName, + path: path.relative(repoRoot, rpPath), + service: serviceName, + service_groups: serviceGroups + }); + } else if (!withServiceGroups && serviceGroups.length === 0 && hasVersionDirectories(rpPath)) { + results.push({ + name: rpName, + path: path.relative(repoRoot, rpPath), + service: serviceName + }); + } + } + } + return results.sort((a, b) => a.name.localeCompare(b.name)); +} + +function formatOutput(rps, format, withSG) { + if (format === 'json') return JSON.stringify(rps, null, 2); + if (rps.length === 0) return `No resource providers ${withSG ? 'with' : 'without'} service groups found.`; + + if (format === 'table') { + const maxSvc = Math.max(...rps.map(r => r.service.length)); + const maxName = Math.max(...rps.map(r => r.name.length)); + const header = withSG + ? `${'Service'.padEnd(maxSvc)} ${'Resource Provider'.padEnd(maxName)} Service Groups` + : `${'Service'.padEnd(maxSvc)} ${'Resource Provider'.padEnd(maxName)} Path`; + const sep = `${'-'.repeat(maxSvc)} ${'-'.repeat(maxName)} ${'-'.repeat(60)}`; + const rows = withSG + ? rps.map(r => `${r.service.padEnd(maxSvc)} ${r.name.padEnd(maxName)} ${r.service_groups.join(', ')}`) + : rps.map(r => `${r.service.padEnd(maxSvc)} ${r.name.padEnd(maxName)} ${r.path}`); + return [header, sep, ...rows].join('\n'); + } + + return withSG + ? rps.map(r => `${r.service}, ${r.name}, [${r.service_groups.join(', ')}]`).join('\n') + : rps.map(r => `${r.service}, ${r.name}`).join('\n'); +} + +function main() { + const args = { repoRoot: null, format: 'list', count: false, withSG: false }; + + for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg === '--help' || arg === '-h') { + console.log('Usage: node fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] [--repo-root PATH]'); + return 0; + } + if (arg === '--repo-root') args.repoRoot = process.argv[++i]; + else if (arg === '--format') args.format = process.argv[++i]; + else if (arg === '--count') args.count = true; + else if (arg === '--with-service-groups') args.withSG = true; + } + + try { + const repoRoot = args.repoRoot ? path.resolve(args.repoRoot) : findRepoRoot(); + const rps = findResourceProviders(repoRoot, args.withSG); + + if (args.count) { + console.log(rps.length); + } else { + console.log(formatOutput(rps, args.format, args.withSG)); + if (args.format !== 'json') { + console.log(`\nTotal: ${rps.length} resource provider(s) ${args.withSG ? 'with' : 'without'} service groups`); + } + } + return 0; + } catch (error) { + console.error(`Error: ${error.message}`); + return 1; + } +} + +if (require.main === module) process.exit(main()); + +module.exports = { findRepoRoot, findResourceProviders, formatOutput, isServiceGroupDirectory, hasVersionDirectories }; diff --git a/eng/scripts/generate-lease-files.js b/eng/scripts/generate-lease-files.js new file mode 100644 index 000000000000..91b9e310fe5c --- /dev/null +++ b/eng/scripts/generate-lease-files.js @@ -0,0 +1,480 @@ +#!/usr/bin/env node +// Generate lease.yaml files from resource provider data + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +const DEFAULT_DURATION = 'P180D'; +const LEASE_BASE_PATH = '.github/arm-leases'; + +function parseArgs() { + const args = { + input: null, + service: null, + resourceProvider: null, + serviceGroups: [], + reviewer: null, + startdate: null, + duration: DEFAULT_DURATION, + repoRoot: null, + dryRun: false, + interactive: false, + }; + + for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + switch (arg) { + case '--help': + case '-h': + printHelp(); + process.exit(0); + case '--input': + args.input = process.argv[++i]; + break; + case '--service': + args.service = process.argv[++i]; + break; + case '--resource-provider': + case '--rp': + args.resourceProvider = process.argv[++i]; + break; + case '--service-groups': + case '--sg': + args.serviceGroups = process.argv[++i].split(',').map(s => s.trim()).filter(Boolean); + break; + case '--reviewer': + args.reviewer = process.argv[++i]; + break; + case '--startdate': + args.startdate = process.argv[++i]; + break; + case '--duration': + args.duration = process.argv[++i]; + break; + case '--repo-root': + args.repoRoot = process.argv[++i]; + break; + case '--dry-run': + args.dryRun = true; + break; + case '--interactive': + case '-i': + args.interactive = true; + break; + default: + console.error(`Unknown argument: ${arg}`); + process.exit(1); + } + } + + return args; +} + +function printHelp() { + console.log(` +Generate lease.yaml files for Azure Resource Providers + +Usage: + Single RP: + node generate-lease-files.js --service --resource-provider --reviewer [options] + + From input file: + node generate-lease-files.js --input --reviewer [options] + + Interactive mode: + node generate-lease-files.js --interactive + +Options: + --service Service name (lowercase alphanumeric) + --resource-provider, --rp Resource provider name (e.g., Microsoft.Test) + --service-groups, --sg Comma-separated service groups (e.g., DiskRP,ComputeRP) + --reviewer Reviewer GitHub alias starting with @ (e.g., @githubUser) + --startdate Lease start date (default: today) + --duration Lease duration (default: P180D, max: P180D) + --repo-root Repository root path (auto-detected if not provided) + --dry-run Show what would be created without writing files + --interactive, -i Interactive mode with prompts + --help, -h Show this help message + +Input File Format: + The input file should contain one entry per line in CSV format: + - Without service groups: service, resource_provider + - With service groups: service, resource_provider, [group1, group2, ...] + + Example: + storage, Microsoft.Storage + compute, Microsoft.Compute, [ComputeRP, DiskRP, GalleryRP] + +Examples: + # Single RP without service groups + node generate-lease-files.js --service storage --rp Microsoft.Storage --reviewer "@johnDoe" + + # Single RP with service groups + node generate-lease-files.js --service compute --rp Microsoft.Compute \\ + --sg "ComputeRP,DiskRP" --reviewer "@janeSmith" + + # From fetch-resource-providers.js output + node fetch-resource-providers.js > rps.txt + node generate-lease-files.js --input rps.txt --reviewer "@johnDoe" + + # From fetch-resource-providers.js with service groups + node fetch-resource-providers.js --with-service-groups > rps-with-groups.txt + node generate-lease-files.js --input rps-with-groups.txt --reviewer "@janeSmith" + + # Interactive mode + node generate-lease-files.js --interactive + + # Dry run to preview + node generate-lease-files.js --input rps.txt --reviewer "@testUser" --dry-run + +Output: + Creates lease.yaml files at: + .github/arm-leases///[/]lease.yaml + + Each file contains: + lease: + resource-provider: + startdate: + duration: + reviewer: <@githubAlias> +`); +} + +function findRepoRoot(startPath = process.cwd()) { + let current = path.resolve(startPath); + for (let i = 0; i < 6; i++) { + if (fs.existsSync(path.join(current, 'specification'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + throw new Error('Could not find repository root. Run from within azure-rest-api-specs.'); +} + +function validateStartDate(date) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD`); + } + const dateObj = new Date(date); + if (isNaN(dateObj.getTime())) { + throw new Error(`Invalid calendar date: ${date}`); + } + const gracePeriodDate = new Date(); + gracePeriodDate.setHours(0, 0, 0, 0); + gracePeriodDate.setDate(gracePeriodDate.getDate() - 10); + + if (dateObj < gracePeriodDate) { + throw new Error(`Startdate is too far in the past: ${date} (must be within 10 days of today)`); + } + + return date; +} + +function validateDuration(duration) { + const match = duration.match(/^P(\d+)D$/i); + if (!match) { + throw new Error(`Invalid duration format: ${duration}. Expected P#D (e.g., P180D)`); + } + + const days = parseInt(match[1], 10); + if (days <= 0 || days > 180) { + throw new Error(`Duration must be between 1 and 180 days. Got: ${days}`); + } + + return duration.toUpperCase(); +} + +function validateResourceProvider(rp) { + const parts = rp.split('.'); + for (const part of parts) { + if (!/^[A-Z]/.test(part)) { + throw new Error(`Resource provider parts must start with capital letter: ${rp}`); + } + } + return rp; +} + +function validateServiceName(service) { + if (!/^[a-z0-9]+$/.test(service)) { + throw new Error(`Service name must be lowercase alphanumeric: ${service}`); + } + return service; +} + +function validateReviewer(reviewer) { + if (!reviewer || reviewer.trim().length === 0) { + throw new Error('Reviewer is required and cannot be empty'); + } + const trimmed = reviewer.trim(); + if (!trimmed.startsWith('@') || trimmed.length <= 1) { + throw new Error(`Reviewer must be a GitHub alias starting with @ (e.g., @githubUser). Got: ${reviewer}`); + } + return trimmed; +} + +function parseInputLine(line) { + line = line.trim(); + if (!line || line.startsWith('#')) { + return null; + } + + const match = line.match(/^([^,]+),\s*([^,\[]+)(?:,\s*\[([^\]]+)\])?$/); + if (!match) { + console.warn(`Skipping invalid line: ${line}`); + return null; + } + + const service = match[1].trim(); + const resourceProvider = match[2].trim(); + const serviceGroupsStr = match[3]; + const serviceGroups = serviceGroupsStr + ? serviceGroupsStr.split(',').map(s => s.trim()).filter(Boolean) + : []; + + return { service, resourceProvider, serviceGroups }; +} + +function generateLeaseYaml(resourceProvider, startdate, duration, reviewer) { + return `lease: + resource-provider: ${resourceProvider} + startdate: ${startdate} + duration: ${duration} + reviewer: ${reviewer} +`; +} + +function getLeasePath(repoRoot, service, resourceProvider, serviceGroup = null) { + const basePath = path.join(repoRoot, LEASE_BASE_PATH, service, resourceProvider); + if (serviceGroup) { + return path.join(basePath, serviceGroup, 'lease.yaml'); + } + return path.join(basePath, 'lease.yaml'); +} + +function createLeaseFile(filePath, content, dryRun = false) { + if (dryRun) { + console.log(`[DRY RUN] Would create: ${filePath}`); + console.log(content); + console.log('---'); + return; + } + + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(filePath)) { + console.warn(`Warning: File already exists, skipping: ${filePath}`); + return; + } + + fs.writeFileSync(filePath, content, 'utf-8'); + console.log(`Created: ${filePath}`); +} + +function processEntry(entry, args, repoRoot) { + const { service, resourceProvider, serviceGroups } = entry; + const { startdate, duration, reviewer, dryRun } = args; + + try { + validateServiceName(service); + validateResourceProvider(resourceProvider); + + const content = generateLeaseYaml(resourceProvider, startdate, duration, reviewer); + + if (serviceGroups.length === 0) { + const leasePath = getLeasePath(repoRoot, service, resourceProvider); + createLeaseFile(leasePath, content, dryRun); + } else { + for (const group of serviceGroups) { + const leasePath = getLeasePath(repoRoot, service, resourceProvider, group); + createLeaseFile(leasePath, content, dryRun); + } + } + } catch (error) { + console.error(`Error processing ${service}/${resourceProvider}: ${error.message}`); + } +} + +async function promptInteractive() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + const question = (prompt) => new Promise((resolve) => { + rl.question(prompt, resolve); + }); + + console.log('\nInteractive Lease File Generator'); + console.log('=================================\n'); + + const reviewer = await question('Enter reviewer GitHub alias (required, e.g., @githubUser): '); + if (!reviewer.trim()) { + console.error('Error: Reviewer name is required'); + rl.close(); + process.exit(1); + } + + const startdateInput = await question(`Enter start date [YYYY-MM-DD] (default: ${getTodayDate()}): `); + const startdate = startdateInput.trim() || getTodayDate(); + + const durationInput = await question(`Enter duration [P#D] (default: ${DEFAULT_DURATION}): `); + const duration = durationInput.trim() || DEFAULT_DURATION; + + console.log('\nEnter resource provider entries (one per line, empty line to finish):'); + console.log('Format: service, resource_provider, [optional_groups]'); + console.log('Example: storage, Microsoft.Storage'); + console.log('Example: compute, Microsoft.Compute, [ComputeRP, DiskRP]\n'); + + const entries = []; + while (true) { + const line = await question('> '); + if (!line.trim()) break; + + const entry = parseInputLine(line); + if (entry) { + entries.push(entry); + } + } + + rl.close(); + + return { + reviewer: reviewer.trim(), + startdate, + duration, + entries, + dryRun: false, + }; +} + +function getTodayDate() { + const today = new Date(); + return today.toISOString().split('T')[0]; +} + +async function main() { + let args = parseArgs(); + + if (args.interactive) { + const interactive = await promptInteractive(); + args.reviewer = interactive.reviewer; + args.startdate = interactive.startdate; + args.duration = interactive.duration; + + if (interactive.entries.length === 0) { + console.error('Error: No entries provided'); + return 1; + } + + try { + const repoRoot = args.repoRoot ? path.resolve(args.repoRoot) : findRepoRoot(); + const reviewer = validateReviewer(args.reviewer); + const startdate = validateStartDate(args.startdate); + const duration = validateDuration(args.duration); + + console.log(`\nRepository root: ${repoRoot}`); + console.log(`Reviewer: ${reviewer}`); + console.log(`Start date: ${startdate}`); + console.log(`Duration: ${duration}\n`); + + for (const entry of interactive.entries) { + processEntry(entry, { ...args, startdate, duration, reviewer }, repoRoot); + } + + console.log(`\nProcessed ${interactive.entries.length} entries`); + return 0; + } catch (error) { + console.error(`Error: ${error.message}`); + return 1; + } + } + + if (!args.reviewer) { + console.error('Error: --reviewer is required'); + console.error('Use --help for usage information'); + return 1; + } + + if (!args.startdate) { + args.startdate = getTodayDate(); + } + + try { + const repoRoot = args.repoRoot ? path.resolve(args.repoRoot) : findRepoRoot(); + const reviewer = validateReviewer(args.reviewer); + const startdate = validateStartDate(args.startdate); + const duration = validateDuration(args.duration); + + console.log(`Repository root: ${repoRoot}`); + console.log(`Reviewer: ${reviewer}`); + console.log(`Start date: ${startdate}`); + console.log(`Duration: ${duration}\n`); + + const entries = []; + + if (args.input) { + const inputPath = path.resolve(args.input); + if (!fs.existsSync(inputPath)) { + throw new Error(`Input file not found: ${inputPath}`); + } + + const content = fs.readFileSync(inputPath, 'utf-8'); + const lines = content.split('\n'); + + for (const line of lines) { + const entry = parseInputLine(line); + if (entry) { + entries.push(entry); + } + } + + if (entries.length === 0) { + console.warn('Warning: No valid entries found in input file'); + return 0; + } + } else if (args.service && args.resourceProvider) { + entries.push({ + service: args.service, + resourceProvider: args.resourceProvider, + serviceGroups: args.serviceGroups, + }); + } else { + console.error('Error: Either --input or both --service and --resource-provider are required'); + console.error('Use --help for usage information'); + return 1; + } + + for (const entry of entries) { + processEntry(entry, { ...args, startdate, duration, reviewer }, repoRoot); + } + + console.log(`\nProcessed ${entries.length} entries`); + return 0; + + } catch (error) { + console.error(`Error: ${error.message}`); + return 1; + } +} + +if (require.main === module) { + main().then(code => process.exit(code)); +} + +module.exports = { + parseInputLine, + generateLeaseYaml, + getLeasePath, + validateStartDate, + validateDuration, + validateResourceProvider, + validateServiceName, + validateReviewer, + getTodayDate, +}; From 29e42d3fae07bf75616ed4b787b47d1228467687 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Thu, 16 Apr 2026 12:41:10 -0500 Subject: [PATCH 03/18] Few manual tweaks --- .github/arm-leases/README.md | 44 ++++++++++ eng/scripts/fetch-resource-providers.js | 95 +++++++++++--------- eng/scripts/generate-lease-files.js | 111 ++++++++++++------------ 3 files changed, 153 insertions(+), 97 deletions(-) diff --git a/.github/arm-leases/README.md b/.github/arm-leases/README.md index 327e201ec8d3..7d81dcf1dbba 100644 --- a/.github/arm-leases/README.md +++ b/.github/arm-leases/README.md @@ -95,3 +95,47 @@ If your PR check **"ARM Lease Validation"** is failing, review the error message - **Invalid duration**: Use a valid ISO 8601 duration that does not exceed 180 days (e.g., `P180D`, `P90D`, `P5M`) - **Missing or empty fields**: All fields (`resource-provider`, `startdate`, `duration`, `reviewer`) are required - **Disallowed files**: Only `lease.yaml` and `README.md` files are permitted in `.github/arm-leases/` + +## Automation Scripts + +Scripts are located in `eng/scripts/`. Run from the repository root. + +### Single Resource Provider + +**Syntax:** +```bash +# Without service groups +node eng/scripts/generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration + +# With service groups +node eng/scripts/generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration --serviceName "," +``` + +**Examples:** +```bash +# Without service groups +node eng/scripts/generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" --startdate 2026-05-01 --duration P90D + +# With service groups +node eng/scripts/generate-lease-files.js --orgName compute --rpNamespace Microsoft.Compute --reviewer "@janesmith" --startdate 2026-05-01 --duration P180D --serviceName "DiskRP,ComputeRP,GalleryRP" +``` + +### Bulk Generation + +**Syntax:** +```bash +# Generate resource provider list +node eng/scripts/fetch-resource-providers.js --with-service-groups --output + +# Generate lease files from list +node eng/scripts/generate-lease-files.js --input --reviewer "@youralias" --startdate --duration +``` + +**Examples:** +```bash +# Generate resource provider list +node eng/scripts/fetch-resource-providers.js --with-service-groups --output rps-groups.txt + +# Generate lease files from list +node eng/scripts/generate-lease-files.js --input rps-groups.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D +``` diff --git a/eng/scripts/fetch-resource-providers.js b/eng/scripts/fetch-resource-providers.js index c645f6f01473..3ccc5f780b8b 100644 --- a/eng/scripts/fetch-resource-providers.js +++ b/eng/scripts/fetch-resource-providers.js @@ -1,11 +1,11 @@ #!/usr/bin/env node -// Fetch Azure resource providers with or without service groups -// Usage: node fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] +// Fetch Azure resource providers with or without service names (service groups) +// Usage: node fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] [--output FILE] const fs = require('fs'); const path = require('path'); -function isServiceGroupDirectory(dirPath) { +function isServiceNameDirectory(dirPath) { const excludeNames = new Set(['stable', 'preview', 'common-types', 'examples']); try { return !excludeNames.has(path.basename(dirPath)) && fs.statSync(dirPath).isDirectory(); @@ -27,94 +27,103 @@ function findRepoRoot(startPath = process.cwd()) { throw new Error('Could not find repository root. Run from within azure-rest-api-specs.'); } -function findResourceProviders(repoRoot, withServiceGroups = false) { +function findResourceProviders(repoRoot, withServiceNames = false) { const results = []; const specDir = path.join(repoRoot, 'specification'); if (!fs.existsSync(specDir)) throw new Error(`Specification directory not found: ${specDir}`); - for (const serviceName of fs.readdirSync(specDir)) { - const serviceDir = path.join(specDir, serviceName); - if (!fs.statSync(serviceDir).isDirectory()) continue; + for (const orgName of fs.readdirSync(specDir)) { + const orgDir = path.join(specDir, orgName); + if (!fs.statSync(orgDir).isDirectory()) continue; - const rmDir = path.join(serviceDir, 'resource-manager'); + const rmDir = path.join(orgDir, 'resource-manager'); if (!fs.existsSync(rmDir)) continue; - for (const rpName of fs.readdirSync(rmDir)) { - const rpPath = path.join(rmDir, rpName); - if (!fs.statSync(rpPath).isDirectory() || !rpName.startsWith('Microsoft.')) continue; + for (const rpNamespace of fs.readdirSync(rmDir)) { + const rpPath = path.join(rmDir, rpNamespace); + if (!fs.statSync(rpPath).isDirectory() || !rpNamespace.startsWith('Microsoft.')) continue; - const serviceGroups = fs.readdirSync(rpPath) - .filter(sg => isServiceGroupDirectory(path.join(rpPath, sg))) + const serviceNames = fs.readdirSync(rpPath) + .filter(sn => isServiceNameDirectory(path.join(rpPath, sn))) .sort(); - if (withServiceGroups && serviceGroups.length > 0) { + if (withServiceNames && serviceNames.length > 0) { results.push({ - name: rpName, + rpNamespace: rpNamespace, path: path.relative(repoRoot, rpPath), - service: serviceName, - service_groups: serviceGroups + orgName: orgName, + serviceNames: serviceNames }); - } else if (!withServiceGroups && serviceGroups.length === 0 && hasVersionDirectories(rpPath)) { + } else if (!withServiceNames && serviceNames.length === 0 && hasVersionDirectories(rpPath)) { results.push({ - name: rpName, + rpNamespace: rpNamespace, path: path.relative(repoRoot, rpPath), - service: serviceName + orgName: orgName }); } } } - return results.sort((a, b) => a.name.localeCompare(b.name)); + return results.sort((a, b) => a.rpNamespace.localeCompare(b.rpNamespace)); } -function formatOutput(rps, format, withSG) { +function formatOutput(rps, format, withSN) { if (format === 'json') return JSON.stringify(rps, null, 2); - if (rps.length === 0) return `No resource providers ${withSG ? 'with' : 'without'} service groups found.`; + if (rps.length === 0) return `No resource providers ${withSN ? 'with' : 'without'} serviceNames found.`; if (format === 'table') { - const maxSvc = Math.max(...rps.map(r => r.service.length)); - const maxName = Math.max(...rps.map(r => r.name.length)); - const header = withSG - ? `${'Service'.padEnd(maxSvc)} ${'Resource Provider'.padEnd(maxName)} Service Groups` - : `${'Service'.padEnd(maxSvc)} ${'Resource Provider'.padEnd(maxName)} Path`; - const sep = `${'-'.repeat(maxSvc)} ${'-'.repeat(maxName)} ${'-'.repeat(60)}`; - const rows = withSG - ? rps.map(r => `${r.service.padEnd(maxSvc)} ${r.name.padEnd(maxName)} ${r.service_groups.join(', ')}`) - : rps.map(r => `${r.service.padEnd(maxSvc)} ${r.name.padEnd(maxName)} ${r.path}`); + const maxOrg = Math.max(...rps.map(r => r.orgName.length)); + const maxRp = Math.max(...rps.map(r => r.rpNamespace.length)); + const header = withSN + ? `${'orgName'.padEnd(maxOrg)} ${'rpNamespace'.padEnd(maxRp)} serviceNames` + : `${'orgName'.padEnd(maxOrg)} ${'rpNamespace'.padEnd(maxRp)} Path`; + const sep = `${'-'.repeat(maxOrg)} ${'-'.repeat(maxRp)} ${'-'.repeat(60)}`; + const rows = withSN + ? rps.map(r => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.serviceNames.join(', ')}`) + : rps.map(r => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.path}`); return [header, sep, ...rows].join('\n'); } - return withSG - ? rps.map(r => `${r.service}, ${r.name}, [${r.service_groups.join(', ')}]`).join('\n') - : rps.map(r => `${r.service}, ${r.name}`).join('\n'); + return withSN + ? rps.map(r => `${r.orgName}, ${r.rpNamespace}, [${r.serviceNames.join(', ')}]`).join('\n') + : rps.map(r => `${r.orgName}, ${r.rpNamespace}`).join('\n'); } function main() { - const args = { repoRoot: null, format: 'list', count: false, withSG: false }; + const args = { repoRoot: null, format: 'list', count: false, withSN: false, output: null }; for (let i = 2; i < process.argv.length; i++) { const arg = process.argv[i]; if (arg === '--help' || arg === '-h') { - console.log('Usage: node fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] [--repo-root PATH]'); + console.log('Usage: node fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] [--output FILE] [--repo-root PATH]'); return 0; } if (arg === '--repo-root') args.repoRoot = process.argv[++i]; else if (arg === '--format') args.format = process.argv[++i]; + else if (arg === '--output' || arg === '-o') args.output = process.argv[++i]; else if (arg === '--count') args.count = true; - else if (arg === '--with-service-groups') args.withSG = true; + else if (arg === '--with-service-groups') args.withSN = true; } try { const repoRoot = args.repoRoot ? path.resolve(args.repoRoot) : findRepoRoot(); - const rps = findResourceProviders(repoRoot, args.withSG); + const rps = findResourceProviders(repoRoot, args.withSN); + let outputText; if (args.count) { - console.log(rps.length); + outputText = String(rps.length); } else { - console.log(formatOutput(rps, args.format, args.withSG)); + outputText = formatOutput(rps, args.format, args.withSN); if (args.format !== 'json') { - console.log(`\nTotal: ${rps.length} resource provider(s) ${args.withSG ? 'with' : 'without'} service groups`); + outputText += `\n\nTotal: ${rps.length} resource provider(s) ${args.withSN ? 'with' : 'without'} serviceNames`; } } + + if (args.output) { + fs.writeFileSync(args.output, outputText + '\n'); + console.log(`Output written to ${args.output}`); + } else { + console.log(outputText); + } return 0; } catch (error) { console.error(`Error: ${error.message}`); @@ -124,4 +133,4 @@ function main() { if (require.main === module) process.exit(main()); -module.exports = { findRepoRoot, findResourceProviders, formatOutput, isServiceGroupDirectory, hasVersionDirectories }; +module.exports = { findRepoRoot, findResourceProviders, formatOutput, isServiceNameDirectory, hasVersionDirectories }; diff --git a/eng/scripts/generate-lease-files.js b/eng/scripts/generate-lease-files.js index 91b9e310fe5c..b7edf52cd471 100644 --- a/eng/scripts/generate-lease-files.js +++ b/eng/scripts/generate-lease-files.js @@ -11,9 +11,9 @@ const LEASE_BASE_PATH = '.github/arm-leases'; function parseArgs() { const args = { input: null, - service: null, - resourceProvider: null, - serviceGroups: [], + orgName: null, + rpNamespace: null, + serviceNames: [], reviewer: null, startdate: null, duration: DEFAULT_DURATION, @@ -32,16 +32,19 @@ function parseArgs() { case '--input': args.input = process.argv[++i]; break; + case '--orgName': case '--service': - args.service = process.argv[++i]; + args.orgName = process.argv[++i]; break; + case '--rpNamespace': case '--resource-provider': case '--rp': - args.resourceProvider = process.argv[++i]; + args.rpNamespace = process.argv[++i]; break; + case '--serviceName': case '--service-groups': case '--sg': - args.serviceGroups = process.argv[++i].split(',').map(s => s.trim()).filter(Boolean); + args.serviceNames = process.argv[++i].split(',').map(s => s.trim()).filter(Boolean); break; case '--reviewer': args.reviewer = process.argv[++i]; @@ -77,7 +80,7 @@ Generate lease.yaml files for Azure Resource Providers Usage: Single RP: - node generate-lease-files.js --service --resource-provider --reviewer [options] + node generate-lease-files.js --orgName --rpNamespace --reviewer [options] From input file: node generate-lease-files.js --input --reviewer [options] @@ -86,9 +89,9 @@ Usage: node generate-lease-files.js --interactive Options: - --service Service name (lowercase alphanumeric) - --resource-provider, --rp Resource provider name (e.g., Microsoft.Test) - --service-groups, --sg Comma-separated service groups (e.g., DiskRP,ComputeRP) + --orgName Organization/service name (lowercase alphanumeric) + --rpNamespace Resource provider namespace (e.g., Microsoft.Storage) + --serviceName Comma-separated service groups (e.g., DiskRP,ComputeRP) --reviewer Reviewer GitHub alias starting with @ (e.g., @githubUser) --startdate Lease start date (default: today) --duration Lease duration (default: P180D, max: P180D) @@ -99,8 +102,8 @@ Options: Input File Format: The input file should contain one entry per line in CSV format: - - Without service groups: service, resource_provider - - With service groups: service, resource_provider, [group1, group2, ...] + - Without service groups: orgName, rpNamespace + - With service groups: orgName, rpNamespace, [serviceName1, serviceName2, ...] Example: storage, Microsoft.Storage @@ -108,11 +111,11 @@ Input File Format: Examples: # Single RP without service groups - node generate-lease-files.js --service storage --rp Microsoft.Storage --reviewer "@johnDoe" + node generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johnDoe" # Single RP with service groups - node generate-lease-files.js --service compute --rp Microsoft.Compute \\ - --sg "ComputeRP,DiskRP" --reviewer "@janeSmith" + node generate-lease-files.js --orgName compute --rpNamespace Microsoft.Compute \\ + --serviceName "ComputeRP,DiskRP" --reviewer "@janeSmith" # From fetch-resource-providers.js output node fetch-resource-providers.js > rps.txt @@ -130,11 +133,11 @@ Examples: Output: Creates lease.yaml files at: - .github/arm-leases///[/]lease.yaml + .github/arm-leases///[/]lease.yaml Each file contains: lease: - resource-provider: + resource-provider: startdate: duration: reviewer: <@githubAlias> @@ -187,21 +190,21 @@ function validateDuration(duration) { return duration.toUpperCase(); } -function validateResourceProvider(rp) { - const parts = rp.split('.'); +function validateRpNamespace(rpNamespace) { + const parts = rpNamespace.split('.'); for (const part of parts) { if (!/^[A-Z]/.test(part)) { - throw new Error(`Resource provider parts must start with capital letter: ${rp}`); + throw new Error(`rpNamespace parts must start with capital letter: ${rpNamespace}`); } } - return rp; + return rpNamespace; } -function validateServiceName(service) { - if (!/^[a-z0-9]+$/.test(service)) { - throw new Error(`Service name must be lowercase alphanumeric: ${service}`); +function validateOrgName(orgName) { + if (!/^[a-z0-9]+$/.test(orgName)) { + throw new Error(`orgName must be lowercase alphanumeric: ${orgName}`); } - return service; + return orgName; } function validateReviewer(reviewer) { @@ -227,29 +230,29 @@ function parseInputLine(line) { return null; } - const service = match[1].trim(); - const resourceProvider = match[2].trim(); - const serviceGroupsStr = match[3]; - const serviceGroups = serviceGroupsStr - ? serviceGroupsStr.split(',').map(s => s.trim()).filter(Boolean) + const orgName = match[1].trim(); + const rpNamespace = match[2].trim(); + const serviceNamesStr = match[3]; + const serviceNames = serviceNamesStr + ? serviceNamesStr.split(',').map(s => s.trim()).filter(Boolean) : []; - return { service, resourceProvider, serviceGroups }; + return { orgName, rpNamespace, serviceNames }; } -function generateLeaseYaml(resourceProvider, startdate, duration, reviewer) { +function generateLeaseYaml(rpNamespace, startdate, duration, reviewer) { return `lease: - resource-provider: ${resourceProvider} + resource-provider: ${rpNamespace} startdate: ${startdate} duration: ${duration} reviewer: ${reviewer} `; } -function getLeasePath(repoRoot, service, resourceProvider, serviceGroup = null) { - const basePath = path.join(repoRoot, LEASE_BASE_PATH, service, resourceProvider); - if (serviceGroup) { - return path.join(basePath, serviceGroup, 'lease.yaml'); +function getLeasePath(repoRoot, orgName, rpNamespace, serviceName = null) { + const basePath = path.join(repoRoot, LEASE_BASE_PATH, orgName, rpNamespace); + if (serviceName) { + return path.join(basePath, serviceName, 'lease.yaml'); } return path.join(basePath, 'lease.yaml'); } @@ -277,26 +280,26 @@ function createLeaseFile(filePath, content, dryRun = false) { } function processEntry(entry, args, repoRoot) { - const { service, resourceProvider, serviceGroups } = entry; + const { orgName, rpNamespace, serviceNames } = entry; const { startdate, duration, reviewer, dryRun } = args; try { - validateServiceName(service); - validateResourceProvider(resourceProvider); + validateOrgName(orgName); + validateRpNamespace(rpNamespace); - const content = generateLeaseYaml(resourceProvider, startdate, duration, reviewer); + const content = generateLeaseYaml(rpNamespace, startdate, duration, reviewer); - if (serviceGroups.length === 0) { - const leasePath = getLeasePath(repoRoot, service, resourceProvider); + if (serviceNames.length === 0) { + const leasePath = getLeasePath(repoRoot, orgName, rpNamespace); createLeaseFile(leasePath, content, dryRun); } else { - for (const group of serviceGroups) { - const leasePath = getLeasePath(repoRoot, service, resourceProvider, group); + for (const serviceName of serviceNames) { + const leasePath = getLeasePath(repoRoot, orgName, rpNamespace, serviceName); createLeaseFile(leasePath, content, dryRun); } } } catch (error) { - console.error(`Error processing ${service}/${resourceProvider}: ${error.message}`); + console.error(`Error processing ${orgName}/${rpNamespace}: ${error.message}`); } } @@ -327,7 +330,7 @@ async function promptInteractive() { const duration = durationInput.trim() || DEFAULT_DURATION; console.log('\nEnter resource provider entries (one per line, empty line to finish):'); - console.log('Format: service, resource_provider, [optional_groups]'); + console.log('Format: orgName, rpNamespace, [optional serviceNames]'); console.log('Example: storage, Microsoft.Storage'); console.log('Example: compute, Microsoft.Compute, [ComputeRP, DiskRP]\n'); @@ -438,14 +441,14 @@ async function main() { console.warn('Warning: No valid entries found in input file'); return 0; } - } else if (args.service && args.resourceProvider) { + } else if (args.orgName && args.rpNamespace) { entries.push({ - service: args.service, - resourceProvider: args.resourceProvider, - serviceGroups: args.serviceGroups, + orgName: args.orgName, + rpNamespace: args.rpNamespace, + serviceNames: args.serviceNames, }); } else { - console.error('Error: Either --input or both --service and --resource-provider are required'); + console.error('Error: Either --input or both --orgName and --rpNamespace are required'); console.error('Use --help for usage information'); return 1; } @@ -473,8 +476,8 @@ module.exports = { getLeasePath, validateStartDate, validateDuration, - validateResourceProvider, - validateServiceName, + validateRpNamespace, + validateOrgName, validateReviewer, getTodayDate, }; From 2ad87dac518195f84208e9e8105d6851c4969218 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Thu, 16 Apr 2026 12:55:44 -0500 Subject: [PATCH 04/18] Prettier check --- .github/arm-leases/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/arm-leases/README.md b/.github/arm-leases/README.md index 7d81dcf1dbba..c94b8a902840 100644 --- a/.github/arm-leases/README.md +++ b/.github/arm-leases/README.md @@ -103,6 +103,7 @@ Scripts are located in `eng/scripts/`. Run from the repository root. ### Single Resource Provider **Syntax:** + ```bash # Without service groups node eng/scripts/generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration @@ -112,6 +113,7 @@ node eng/scripts/generate-lease-files.js --orgName --rpNamespace @@ -132,6 +135,7 @@ node eng/scripts/generate-lease-files.js --input --reviewer "@yourali ``` **Examples:** + ```bash # Generate resource provider list node eng/scripts/fetch-resource-providers.js --with-service-groups --output rps-groups.txt From 9fde3aa4136d26d838dbdeb01b4c526d756ab368 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Mon, 27 Apr 2026 15:13:25 -0500 Subject: [PATCH 05/18] Add trailing line --- .github/arm-leases/README.md | 13 +- .../Tests/fetch-resource-providers.test.js | 198 +++-- .../Tests/generate-lease-files.test.js | 568 +++++++------- eng/scripts/fetch-resource-providers.js | 298 +++++--- eng/scripts/generate-lease-files.js | 711 ++++++++++-------- 5 files changed, 1027 insertions(+), 761 deletions(-) diff --git a/.github/arm-leases/README.md b/.github/arm-leases/README.md index c94b8a902840..fab306e28ea2 100644 --- a/.github/arm-leases/README.md +++ b/.github/arm-leases/README.md @@ -127,7 +127,10 @@ node eng/scripts/generate-lease-files.js --orgName compute --rpNamespace Microso **Syntax:** ```bash -# Generate resource provider list +# Generate resource provider list (without service groups) +node eng/scripts/fetch-resource-providers.js --output + +# Generate resource provider list (with service groups) node eng/scripts/fetch-resource-providers.js --with-service-groups --output # Generate lease files from list @@ -137,9 +140,11 @@ node eng/scripts/generate-lease-files.js --input --reviewer "@yourali **Examples:** ```bash -# Generate resource provider list -node eng/scripts/fetch-resource-providers.js --with-service-groups --output rps-groups.txt +# RPs without service groups (e.g., Microsoft.Storage) +node eng/scripts/fetch-resource-providers.js --output rps-simple.txt +node eng/scripts/generate-lease-files.js --input rps-simple.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D -# Generate lease files from list +# RPs with service groups (e.g., Microsoft.Compute with DiskRP, ComputeRP) +node eng/scripts/fetch-resource-providers.js --with-service-groups --output rps-groups.txt node eng/scripts/generate-lease-files.js --input rps-groups.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D ``` diff --git a/eng/scripts/Tests/fetch-resource-providers.test.js b/eng/scripts/Tests/fetch-resource-providers.test.js index 3fb8db3a5487..2e9d7f9fae17 100644 --- a/eng/scripts/Tests/fetch-resource-providers.test.js +++ b/eng/scripts/Tests/fetch-resource-providers.test.js @@ -1,99 +1,169 @@ #!/usr/bin/env node // Tests for fetch-resource-providers.js -const path = require('path'); -const { findRepoRoot, findResourceProviders, formatOutput } = require('../fetch-resource-providers.js'); +const path = require("path"); +const { + findRepoRoot, + findResourceProviders, + formatOutput, +} = require("../fetch-resource-providers.js"); function assert(condition, message) { - if (!condition) throw new Error(`Test failed: ${message}`); + if (!condition) throw new Error(`Test failed: ${message}`); } function testFindRepoRoot() { - const repoRoot = findRepoRoot(); - assert(repoRoot.endsWith('azure-rest-api-specs'), 'Repo root should end with azure-rest-api-specs'); - assert(require('fs').existsSync(path.join(repoRoot, 'specification')), 'Specification directory should exist'); - console.log('✓ findRepoRoot test passed'); + const repoRoot = findRepoRoot(); + assert( + repoRoot.endsWith("azure-rest-api-specs"), + "Repo root should end with azure-rest-api-specs", + ); + assert( + require("fs").existsSync(path.join(repoRoot, "specification")), + "Specification directory should exist", + ); + console.log("✓ findRepoRoot test passed"); } function testFindResourceProvidersWithout() { - const repoRoot = findRepoRoot(); - const rps = findResourceProviders(repoRoot, false); - assert(rps.length > 0, 'Should find resource providers without service groups'); - assert(rps.every(rp => rp.name && rp.service && rp.path), 'All RPs should have name, service, and path'); - assert(rps.every(rp => !rp.service_groups), 'RPs without service groups should not have service_groups field'); - assert(rps.some(rp => rp.name === 'Microsoft.Storage'), 'Should include Microsoft.Storage'); - assert(!rps.some(rp => rp.name === 'Microsoft.Compute'), 'Should not include Microsoft.Compute (has service groups)'); - console.log(`✓ findResourceProviders (without) test passed - found ${rps.length} RPs`); + const repoRoot = findRepoRoot(); + const rps = findResourceProviders(repoRoot, false); + assert( + rps.length > 0, + "Should find resource providers without service names", + ); + assert( + rps.every((rp) => rp.rpNamespace && rp.orgName && rp.path), + "All RPs should have rpNamespace, orgName, and path", + ); + assert( + rps.every((rp) => !rp.serviceNames), + "RPs without service names should not have serviceNames field", + ); + assert( + rps.some((rp) => rp.rpNamespace === "Microsoft.Storage"), + "Should include Microsoft.Storage", + ); + assert( + !rps.some((rp) => rp.rpNamespace === "Microsoft.Compute"), + "Should not include Microsoft.Compute (has service names)", + ); + console.log( + `✓ findResourceProviders (without) test passed - found ${rps.length} RPs`, + ); } function testFindResourceProvidersWith() { - const repoRoot = findRepoRoot(); - const rps = findResourceProviders(repoRoot, true); - assert(rps.length > 0, 'Should find resource providers with service groups'); - assert(rps.every(rp => rp.name && rp.service && rp.path && rp.service_groups), 'All RPs should have name, service, path, and service_groups'); - assert(rps.every(rp => Array.isArray(rp.service_groups)), 'service_groups should be an array'); - const compute = rps.find(rp => rp.name === 'Microsoft.Compute'); - assert(compute, 'Should include Microsoft.Compute'); - assert(compute.service === 'compute', 'Microsoft.Compute service should be "compute"'); - assert(compute.service_groups.includes('Compute'), 'Microsoft.Compute should have Compute service group'); - assert(!rps.some(rp => rp.name === 'Microsoft.Storage'), 'Should not include Microsoft.Storage (no service groups)'); - console.log(`✓ findResourceProviders (with) test passed - found ${rps.length} RPs`); + const repoRoot = findRepoRoot(); + const rps = findResourceProviders(repoRoot, true); + assert(rps.length > 0, "Should find resource providers with service names"); + assert( + rps.every( + (rp) => rp.rpNamespace && rp.orgName && rp.path && rp.serviceNames, + ), + "All RPs should have rpNamespace, orgName, path, and serviceNames", + ); + assert( + rps.every((rp) => Array.isArray(rp.serviceNames)), + "serviceNames should be an array", + ); + const compute = rps.find((rp) => rp.rpNamespace === "Microsoft.Compute"); + assert(compute, "Should include Microsoft.Compute"); + assert( + compute.orgName === "compute", + 'Microsoft.Compute orgName should be "compute"', + ); + assert( + compute.serviceNames.includes("Compute"), + "Microsoft.Compute should have Compute service name", + ); + assert( + !rps.some((rp) => rp.rpNamespace === "Microsoft.Storage"), + "Should not include Microsoft.Storage (no service names)", + ); + console.log( + `✓ findResourceProviders (with) test passed - found ${rps.length} RPs`, + ); } function testFormatOutputList() { - const rps = [ - { name: 'Microsoft.Test', service: 'test', path: 'test/path' }, - { name: 'Microsoft.Test2', service: 'test2', path: 'test2/path', service_groups: ['Group1', 'Group2'] } - ]; + const rps = [ + { rpNamespace: "Microsoft.Test", orgName: "test", path: "test/path" }, + { + rpNamespace: "Microsoft.Test2", + orgName: "test2", + path: "test2/path", + serviceNames: ["Group1", "Group2"], + }, + ]; - const outputWithout = formatOutput([rps[0]], 'list', false); - assert(outputWithout.includes('test, Microsoft.Test'), 'List format should include service and name'); + const outputWithout = formatOutput([rps[0]], "list", false); + assert( + outputWithout.includes("test, Microsoft.Test"), + "List format should include orgName and rpNamespace", + ); - const outputWith = formatOutput([rps[1]], 'list', true); - assert(outputWith.includes('test2, Microsoft.Test2, [Group1, Group2]'), 'List format with groups should include service, name, and groups'); + const outputWith = formatOutput([rps[1]], "list", true); + assert( + outputWith.includes("test2, Microsoft.Test2, [Group1, Group2]"), + "List format with serviceNames should include orgName, rpNamespace, and serviceNames", + ); - console.log('✓ formatOutput (list) test passed'); + console.log("✓ formatOutput (list) test passed"); } function testFormatOutputJson() { - const rps = [{ name: 'Microsoft.Test', service: 'test', path: 'test/path' }]; - const output = formatOutput(rps, 'json', false); - const parsed = JSON.parse(output); - assert(Array.isArray(parsed), 'JSON output should be an array'); - assert(parsed[0].name === 'Microsoft.Test', 'JSON should preserve RP name'); - console.log('✓ formatOutput (json) test passed'); + const rps = [ + { rpNamespace: "Microsoft.Test", orgName: "test", path: "test/path" }, + ]; + const output = formatOutput(rps, "json", false); + const parsed = JSON.parse(output); + assert(Array.isArray(parsed), "JSON output should be an array"); + assert( + parsed[0].rpNamespace === "Microsoft.Test", + "JSON should preserve RP namespace", + ); + console.log("✓ formatOutput (json) test passed"); } function testFormatOutputTable() { - const rps = [{ name: 'Microsoft.Test', service: 'test', path: 'test/path' }]; - const output = formatOutput(rps, 'table', false); - assert(output.includes('Service'), 'Table should have Service header'); - assert(output.includes('Resource Provider'), 'Table should have Resource Provider header'); - assert(output.includes('test'), 'Table should include service name'); - assert(output.includes('Microsoft.Test'), 'Table should include RP name'); - console.log('✓ formatOutput (table) test passed'); + const rps = [ + { rpNamespace: "Microsoft.Test", orgName: "test", path: "test/path" }, + ]; + const output = formatOutput(rps, "table", false); + assert(output.includes("orgName"), "Table should have orgName header"); + assert( + output.includes("rpNamespace"), + "Table should have rpNamespace header", + ); + assert(output.includes("test"), "Table should include org name"); + assert( + output.includes("Microsoft.Test"), + "Table should include RP namespace", + ); + console.log("✓ formatOutput (table) test passed"); } function runTests() { - console.log('Running tests for fetch-resource-providers.js...\n'); + console.log("Running tests for fetch-resource-providers.js...\n"); - try { - testFindRepoRoot(); - testFindResourceProvidersWithout(); - testFindResourceProvidersWith(); - testFormatOutputList(); - testFormatOutputJson(); - testFormatOutputTable(); + try { + testFindRepoRoot(); + testFindResourceProvidersWithout(); + testFindResourceProvidersWith(); + testFormatOutputList(); + testFormatOutputJson(); + testFormatOutputTable(); - console.log('\n✅ All tests passed!'); - return 0; - } catch (error) { - console.error(`\n❌ ${error.message}`); - console.error(error.stack); - return 1; - } + console.log("\n✅ All tests passed!"); + return 0; + } catch (error) { + console.error(`\n❌ ${error.message}`); + console.error(error.stack); + return 1; + } } if (require.main === module) { - process.exit(runTests()); + process.exit(runTests()); } diff --git a/eng/scripts/Tests/generate-lease-files.test.js b/eng/scripts/Tests/generate-lease-files.test.js index 41481da32e86..c4cab450a9ad 100644 --- a/eng/scripts/Tests/generate-lease-files.test.js +++ b/eng/scripts/Tests/generate-lease-files.test.js @@ -1,310 +1,370 @@ #!/usr/bin/env node // Tests for generate-lease-files.js -const path = require('path'); -const fs = require('fs'); +const path = require("path"); +const fs = require("fs"); const { - parseInputLine, - generateLeaseYaml, - getLeasePath, - validateStartDate, - validateDuration, - validateResourceProvider, - validateServiceName, - validateReviewer, - getTodayDate, -} = require('../generate-lease-files.js'); + parseInputLine, + generateLeaseYaml, + getLeasePath, + validateStartDate, + validateDuration, + validateRpNamespace, + validateOrgName, + validateReviewer, + getTodayDate, +} = require("../generate-lease-files.js"); function assert(condition, message) { - if (!condition) throw new Error(`Test failed: ${message}`); + if (!condition) throw new Error(`Test failed: ${message}`); } function testParseInputLine() { - // Test without service groups - const result1 = parseInputLine('storage, Microsoft.Storage'); - assert(result1.service === 'storage', 'Service should be storage'); - assert(result1.resourceProvider === 'Microsoft.Storage', 'RP should be Microsoft.Storage'); - assert(result1.serviceGroups.length === 0, 'Should have no service groups'); - - // Test with service groups - const result2 = parseInputLine('compute, Microsoft.Compute, [ComputeRP, DiskRP]'); - assert(result2.service === 'compute', 'Service should be compute'); - assert(result2.resourceProvider === 'Microsoft.Compute', 'RP should be Microsoft.Compute'); - assert(result2.serviceGroups.length === 2, 'Should have 2 service groups'); - assert(result2.serviceGroups[0] === 'ComputeRP', 'First group should be ComputeRP'); - assert(result2.serviceGroups[1] === 'DiskRP', 'Second group should be DiskRP'); - - // Test empty line - const result3 = parseInputLine(''); - assert(result3 === null, 'Empty line should return null'); - - // Test comment - const result4 = parseInputLine('# This is a comment'); - assert(result4 === null, 'Comment should return null'); - - console.log('✓ parseInputLine tests passed'); + // Test without service names + const result1 = parseInputLine("storage, Microsoft.Storage"); + assert(result1.orgName === "storage", "Org name should be storage"); + assert( + result1.rpNamespace === "Microsoft.Storage", + "RP should be Microsoft.Storage", + ); + assert(result1.serviceNames.length === 0, "Should have no service names"); + + // Test with service names + const result2 = parseInputLine( + "compute, Microsoft.Compute, [ComputeRP, DiskRP]", + ); + assert(result2.orgName === "compute", "Org name should be compute"); + assert( + result2.rpNamespace === "Microsoft.Compute", + "RP should be Microsoft.Compute", + ); + assert(result2.serviceNames.length === 2, "Should have 2 service names"); + assert( + result2.serviceNames[0] === "ComputeRP", + "First name should be ComputeRP", + ); + assert(result2.serviceNames[1] === "DiskRP", "Second name should be DiskRP"); + + // Test empty line + const result3 = parseInputLine(""); + assert(result3 === null, "Empty line should return null"); + + // Test comment + const result4 = parseInputLine("# This is a comment"); + assert(result4 === null, "Comment should return null"); + + console.log("✓ parseInputLine tests passed"); } function testGenerateLeaseYaml() { - const yaml = generateLeaseYaml('Microsoft.Test', '2026-06-01', 'P180D', '@johnDoe'); - - assert(yaml.includes('resource-provider: Microsoft.Test'), 'Should include resource provider'); - assert(yaml.includes('startdate: 2026-06-01'), 'Should include startdate'); - assert(yaml.includes('duration: P180D'), 'Should include duration (not duration-days)'); - assert(!yaml.includes('duration-days:'), 'Should NOT use duration-days field'); - assert(yaml.includes('reviewer: @johnDoe'), 'Should include reviewer'); - - console.log('✓ generateLeaseYaml tests passed'); + const yaml = generateLeaseYaml( + "Microsoft.Test", + "2026-06-01", + "P180D", + "@johnDoe", + ); + + assert( + yaml.includes("resource-provider: Microsoft.Test"), + "Should include resource provider", + ); + assert(yaml.includes('startdate: "2026-06-01"'), "Should include startdate with quotes"); + assert( + yaml.includes("duration: P180D"), + "Should include duration (not duration-days)", + ); + assert( + !yaml.includes("duration-days:"), + "Should NOT use duration-days field", + ); + assert(yaml.includes('reviewer: "@johnDoe"'), "Should include reviewer with quotes"); + assert(yaml.endsWith("\n\n"), "Should have trailing blank line (6th line)"); + + console.log("✓ generateLeaseYaml tests passed"); } function testGetLeasePath() { - const repoRoot = '/repo'; - - // Without service group - const path1 = getLeasePath(repoRoot, 'storage', 'Microsoft.Storage'); - assert(path1 === '/repo/.github/arm-leases/storage/Microsoft.Storage/lease.yaml', - 'Path without service group should be correct'); - - // With service group - const path2 = getLeasePath(repoRoot, 'compute', 'Microsoft.Compute', 'DiskRP'); - assert(path2 === '/repo/.github/arm-leases/compute/Microsoft.Compute/DiskRP/lease.yaml', - 'Path with service group should be correct'); - - console.log('✓ getLeasePath tests passed'); + const repoRoot = "/repo"; + + // Without service name (use path.join for cross-platform) + const path1 = getLeasePath(repoRoot, "storage", "Microsoft.Storage"); + const expected1 = path.join( + repoRoot, + ".github", + "arm-leases", + "storage", + "Microsoft.Storage", + "lease.yaml", + ); + assert(path1 === expected1, "Path without service name should be correct"); + + // With service name + const path2 = getLeasePath( + repoRoot, + "compute", + "Microsoft.Compute", + "DiskRP", + ); + const expected2 = path.join( + repoRoot, + ".github", + "arm-leases", + "compute", + "Microsoft.Compute", + "DiskRP", + "lease.yaml", + ); + assert(path2 === expected2, "Path with service name should be correct"); + + console.log("✓ getLeasePath tests passed"); } function testValidateStartDate() { - const today = getTodayDate(); - - // Valid date (today) - try { - validateStartDate(today); - console.log(' ✓ Today date is valid'); - } catch (error) { - throw new Error('Today should be valid'); - } - - // Valid date (future) - try { - validateStartDate('2027-12-31'); - console.log(' ✓ Future date is valid'); - } catch (error) { - throw new Error('Future date should be valid'); - } - - // Valid date (within grace period - 5 days ago) - const fiveDaysAgo = new Date(); - fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5); - const fiveDaysAgoStr = fiveDaysAgo.toISOString().split('T')[0]; - try { - validateStartDate(fiveDaysAgoStr); - console.log(' ✓ Date within 10-day grace period is valid'); - } catch (error) { - throw new Error(`Date within 10-day grace period should be valid: ${error.message}`); + const today = getTodayDate(); + + // Valid date (today) + try { + validateStartDate(today); + console.log(" ✓ Today date is valid"); + } catch (error) { + throw new Error("Today should be valid"); + } + + // Valid date (future) + try { + validateStartDate("2027-12-31"); + console.log(" ✓ Future date is valid"); + } catch (error) { + throw new Error("Future date should be valid"); + } + + // Valid date (within grace period - 5 days ago) + const fiveDaysAgo = new Date(); + fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5); + const fiveDaysAgoStr = fiveDaysAgo.toISOString().split("T")[0]; + try { + validateStartDate(fiveDaysAgoStr); + console.log(" ✓ Date within 10-day grace period is valid"); + } catch (error) { + throw new Error( + `Date within 10-day grace period should be valid: ${error.message}`, + ); + } + + // Invalid format + try { + validateStartDate("12/31/2026"); + throw new Error("Should reject invalid format"); + } catch (error) { + if (error.message.includes("Invalid date format")) { + console.log(" ✓ Rejects invalid date format"); + } else { + throw error; } - - // Invalid format - try { - validateStartDate('12/31/2026'); - throw new Error('Should reject invalid format'); - } catch (error) { - if (error.message.includes('Invalid date format')) { - console.log(' ✓ Rejects invalid date format'); - } else { - throw error; - } - } - - // Past date (beyond grace period) - try { - validateStartDate('2020-01-01'); - throw new Error('Should reject date too far in the past'); - } catch (error) { - if (error.message.includes('past')) { - console.log(' ✓ Rejects date too far in the past'); - } else { - throw error; - } + } + + // Past date (beyond grace period) + try { + validateStartDate("2020-01-01"); + throw new Error("Should reject date too far in the past"); + } catch (error) { + if (error.message.includes("past")) { + console.log(" ✓ Rejects date too far in the past"); + } else { + throw error; } + } - console.log('✓ validateStartDate tests passed'); + console.log("✓ validateStartDate tests passed"); } function testValidateDuration() { - // Valid durations - assert(validateDuration('P180D') === 'P180D', 'P180D should be valid'); - assert(validateDuration('P90D') === 'P90D', 'P90D should be valid'); - assert(validateDuration('P1D') === 'P1D', 'P1D should be valid'); - assert(validateDuration('p30d') === 'P30D', 'Should convert to uppercase'); - - // Invalid format - try { - validateDuration('180 days'); - throw new Error('Should reject invalid format'); - } catch (error) { - if (error.message.includes('Invalid duration format')) { - console.log(' ✓ Rejects invalid duration format'); - } else { - throw error; - } + // Valid durations + assert(validateDuration("P180D") === "P180D", "P180D should be valid"); + assert(validateDuration("P90D") === "P90D", "P90D should be valid"); + assert(validateDuration("P1D") === "P1D", "P1D should be valid"); + assert(validateDuration("p30d") === "P30D", "Should convert to uppercase"); + + // Invalid format + try { + validateDuration("180 days"); + throw new Error("Should reject invalid format"); + } catch (error) { + if (error.message.includes("Invalid duration format")) { + console.log(" ✓ Rejects invalid duration format"); + } else { + throw error; } - - // Over 180 days - try { - validateDuration('P200D'); - throw new Error('Should reject over 180 days'); - } catch (error) { - if (error.message.includes('between 1 and 180')) { - console.log(' ✓ Rejects duration over 180 days'); - } else { - throw error; - } + } + + // Over 180 days + try { + validateDuration("P200D"); + throw new Error("Should reject over 180 days"); + } catch (error) { + if (error.message.includes("between 1 and 180")) { + console.log(" ✓ Rejects duration over 180 days"); + } else { + throw error; } - - // Zero days - try { - validateDuration('P0D'); - throw new Error('Should reject 0 days'); - } catch (error) { - if (error.message.includes('between 1 and 180')) { - console.log(' ✓ Rejects zero duration'); - } else { - throw error; - } + } + + // Zero days + try { + validateDuration("P0D"); + throw new Error("Should reject 0 days"); + } catch (error) { + if (error.message.includes("between 1 and 180")) { + console.log(" ✓ Rejects zero duration"); + } else { + throw error; } + } - console.log('✓ validateDuration tests passed'); + console.log("✓ validateDuration tests passed"); } -function testValidateResourceProvider() { - // Valid RPs - validateResourceProvider('Microsoft.Test'); - validateResourceProvider('Azure.Widget'); - validateResourceProvider('Contoso.Manager'); - - // Invalid RPs - try { - validateResourceProvider('microsoft.Test'); - throw new Error('Should reject lowercase start'); - } catch (error) { - if (error.message.includes('capital letter')) { - console.log(' ✓ Rejects lowercase start'); - } else { - throw error; - } +function testValidateRpNamespace() { + // Valid RPs + validateRpNamespace("Microsoft.Test"); + validateRpNamespace("Azure.Widget"); + validateRpNamespace("Contoso.Manager"); + + // Invalid RPs + try { + validateRpNamespace("microsoft.Test"); + throw new Error("Should reject lowercase start"); + } catch (error) { + if (error.message.includes("capital letter")) { + console.log(" ✓ Rejects lowercase start"); + } else { + throw error; } + } - console.log('✓ validateResourceProvider tests passed'); + console.log("✓ validateRpNamespace tests passed"); } -function testValidateServiceName() { - // Valid service names - validateServiceName('storage'); - validateServiceName('compute'); - validateServiceName('testservice123'); - - // Invalid service names - try { - validateServiceName('TestService'); - throw new Error('Should reject uppercase'); - } catch (error) { - if (error.message.includes('lowercase alphanumeric')) { - console.log(' ✓ Rejects uppercase'); - } else { - throw error; - } +function testValidateOrgName() { + // Valid org names + validateOrgName("storage"); + validateOrgName("compute"); + validateOrgName("testservice123"); + + // Invalid org names + try { + validateOrgName("TestService"); + throw new Error("Should reject uppercase"); + } catch (error) { + if (error.message.includes("lowercase alphanumeric")) { + console.log(" ✓ Rejects uppercase"); + } else { + throw error; } - - try { - validateServiceName('test-service'); - throw new Error('Should reject hyphen'); - } catch (error) { - if (error.message.includes('lowercase alphanumeric')) { - console.log(' ✓ Rejects hyphen'); - } else { - throw error; - } + } + + try { + validateOrgName("test-service"); + throw new Error("Should reject hyphen"); + } catch (error) { + if (error.message.includes("lowercase alphanumeric")) { + console.log(" ✓ Rejects hyphen"); + } else { + throw error; } + } - console.log('✓ validateServiceName tests passed'); + console.log("✓ validateOrgName tests passed"); } function testValidateReviewer() { - // Valid reviewers (must start with @) - assert(validateReviewer('@githubUser') === '@githubUser', 'Valid @ reviewer should pass'); - assert(validateReviewer('@johnDoe') === '@johnDoe', 'Valid @ reviewer should pass'); - assert(validateReviewer(' @trimmed ') === '@trimmed', 'Should trim whitespace'); - - // Invalid: missing @ prefix - try { - validateReviewer('githubUser'); - throw new Error('Should reject reviewer without @ prefix'); - } catch (error) { - if (error.message.includes('@')) { - console.log(' ✓ Rejects reviewer without @ prefix'); - } else { - throw error; - } + // Valid reviewers (must start with @) + assert( + validateReviewer("@githubUser") === "@githubUser", + "Valid @ reviewer should pass", + ); + assert( + validateReviewer("@johnDoe") === "@johnDoe", + "Valid @ reviewer should pass", + ); + assert( + validateReviewer(" @trimmed ") === "@trimmed", + "Should trim whitespace", + ); + + // Invalid: missing @ prefix + try { + validateReviewer("githubUser"); + throw new Error("Should reject reviewer without @ prefix"); + } catch (error) { + if (error.message.includes("@")) { + console.log(" ✓ Rejects reviewer without @ prefix"); + } else { + throw error; } - - // Invalid: only @ - try { - validateReviewer('@'); - throw new Error('Should reject @ only'); - } catch (error) { - if (error.message.includes('@')) { - console.log(' ✓ Rejects @ only'); - } else { - throw error; - } + } + + // Invalid: only @ + try { + validateReviewer("@"); + throw new Error("Should reject @ only"); + } catch (error) { + if (error.message.includes("@")) { + console.log(" ✓ Rejects @ only"); + } else { + throw error; } - - // Invalid: empty string - try { - validateReviewer(''); - throw new Error('Should reject empty reviewer'); - } catch (error) { - if (error.message.includes('required')) { - console.log(' ✓ Rejects empty reviewer'); - } else { - throw error; - } + } + + // Invalid: empty string + try { + validateReviewer(""); + throw new Error("Should reject empty reviewer"); + } catch (error) { + if (error.message.includes("required")) { + console.log(" ✓ Rejects empty reviewer"); + } else { + throw error; } + } - console.log('✓ validateReviewer tests passed'); + console.log("✓ validateReviewer tests passed"); } function testGetTodayDate() { - const today = getTodayDate(); - assert(/^\d{4}-\d{2}-\d{2}$/.test(today), 'Today should be in YYYY-MM-DD format'); - console.log(`✓ getTodayDate tests passed (today: ${today})`); + const today = getTodayDate(); + assert( + /^\d{4}-\d{2}-\d{2}$/.test(today), + "Today should be in YYYY-MM-DD format", + ); + console.log(`✓ getTodayDate tests passed (today: ${today})`); } function runTests() { - console.log('Running tests for generate-lease-files.js...\n'); - - try { - testParseInputLine(); - testGenerateLeaseYaml(); - testGetLeasePath(); - testValidateStartDate(); - testValidateDuration(); - testValidateResourceProvider(); - testValidateServiceName(); - testValidateReviewer(); - testGetTodayDate(); - - console.log('\n✅ All tests passed!'); - return 0; - } catch (error) { - console.error(`\n❌ ${error.message}`); - console.error(error.stack); - return 1; - } + console.log("Running tests for generate-lease-files.js...\n"); + + try { + testParseInputLine(); + testGenerateLeaseYaml(); + testGetLeasePath(); + testValidateStartDate(); + testValidateDuration(); + testValidateRpNamespace(); + testValidateOrgName(); + testValidateReviewer(); + testGetTodayDate(); + + console.log("\n✅ All tests passed!"); + return 0; + } catch (error) { + console.error(`\n❌ ${error.message}`); + console.error(error.stack); + return 1; + } } if (require.main === module) { - process.exit(runTests()); + process.exit(runTests()); } module.exports = { runTests }; diff --git a/eng/scripts/fetch-resource-providers.js b/eng/scripts/fetch-resource-providers.js index 3ccc5f780b8b..8a0c892ce41f 100644 --- a/eng/scripts/fetch-resource-providers.js +++ b/eng/scripts/fetch-resource-providers.js @@ -2,135 +2,219 @@ // Fetch Azure resource providers with or without service names (service groups) // Usage: node fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] [--output FILE] -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); function isServiceNameDirectory(dirPath) { - const excludeNames = new Set(['stable', 'preview', 'common-types', 'examples']); - try { - return !excludeNames.has(path.basename(dirPath)) && fs.statSync(dirPath).isDirectory(); - } catch { return false; } + const excludeNames = new Set([ + "stable", + "preview", + "common-types", + "examples", + ]); + try { + return ( + !excludeNames.has(path.basename(dirPath)) && + fs.statSync(dirPath).isDirectory() + ); + } catch { + return false; + } } function hasVersionDirectories(rpPath) { - return fs.existsSync(path.join(rpPath, 'stable')) || fs.existsSync(path.join(rpPath, 'preview')); + return ( + fs.existsSync(path.join(rpPath, "stable")) || + fs.existsSync(path.join(rpPath, "preview")) + ); } function findRepoRoot(startPath = process.cwd()) { - let current = path.resolve(startPath); - for (let i = 0; i < 6; i++) { - if (fs.existsSync(path.join(current, 'specification'))) return current; - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - throw new Error('Could not find repository root. Run from within azure-rest-api-specs.'); + let current = path.resolve(startPath); + for (let i = 0; i < 6; i++) { + if (fs.existsSync(path.join(current, "specification"))) return current; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + throw new Error( + "Could not find repository root. Run from within azure-rest-api-specs.", + ); } function findResourceProviders(repoRoot, withServiceNames = false) { - const results = []; - const specDir = path.join(repoRoot, 'specification'); - if (!fs.existsSync(specDir)) throw new Error(`Specification directory not found: ${specDir}`); - - for (const orgName of fs.readdirSync(specDir)) { - const orgDir = path.join(specDir, orgName); - if (!fs.statSync(orgDir).isDirectory()) continue; - - const rmDir = path.join(orgDir, 'resource-manager'); - if (!fs.existsSync(rmDir)) continue; - - for (const rpNamespace of fs.readdirSync(rmDir)) { - const rpPath = path.join(rmDir, rpNamespace); - if (!fs.statSync(rpPath).isDirectory() || !rpNamespace.startsWith('Microsoft.')) continue; - - const serviceNames = fs.readdirSync(rpPath) - .filter(sn => isServiceNameDirectory(path.join(rpPath, sn))) - .sort(); - - if (withServiceNames && serviceNames.length > 0) { - results.push({ - rpNamespace: rpNamespace, - path: path.relative(repoRoot, rpPath), - orgName: orgName, - serviceNames: serviceNames - }); - } else if (!withServiceNames && serviceNames.length === 0 && hasVersionDirectories(rpPath)) { - results.push({ - rpNamespace: rpNamespace, - path: path.relative(repoRoot, rpPath), - orgName: orgName - }); - } - } + const results = []; + const specDir = path.join(repoRoot, "specification"); + if (!fs.existsSync(specDir)) + throw new Error(`Specification directory not found: ${specDir}`); + + for (const orgName of fs.readdirSync(specDir)) { + const orgDir = path.join(specDir, orgName); + if (!fs.statSync(orgDir).isDirectory()) continue; + + const rmDir = path.join(orgDir, "resource-manager"); + if (!fs.existsSync(rmDir)) continue; + + for (const rpNamespace of fs.readdirSync(rmDir)) { + const rpPath = path.join(rmDir, rpNamespace); + if ( + !fs.statSync(rpPath).isDirectory() || + !rpNamespace.startsWith("Microsoft.") + ) + continue; + + const serviceNames = fs + .readdirSync(rpPath) + .filter((sn) => isServiceNameDirectory(path.join(rpPath, sn))) + .sort(); + + if (withServiceNames && serviceNames.length > 0) { + results.push({ + rpNamespace: rpNamespace, + path: path.relative(repoRoot, rpPath), + orgName: orgName, + serviceNames: serviceNames, + }); + } else if ( + !withServiceNames && + serviceNames.length === 0 && + hasVersionDirectories(rpPath) + ) { + results.push({ + rpNamespace: rpNamespace, + path: path.relative(repoRoot, rpPath), + orgName: orgName, + }); + } } - return results.sort((a, b) => a.rpNamespace.localeCompare(b.rpNamespace)); + } + return results.sort((a, b) => a.rpNamespace.localeCompare(b.rpNamespace)); } function formatOutput(rps, format, withSN) { - if (format === 'json') return JSON.stringify(rps, null, 2); - if (rps.length === 0) return `No resource providers ${withSN ? 'with' : 'without'} serviceNames found.`; - - if (format === 'table') { - const maxOrg = Math.max(...rps.map(r => r.orgName.length)); - const maxRp = Math.max(...rps.map(r => r.rpNamespace.length)); - const header = withSN - ? `${'orgName'.padEnd(maxOrg)} ${'rpNamespace'.padEnd(maxRp)} serviceNames` - : `${'orgName'.padEnd(maxOrg)} ${'rpNamespace'.padEnd(maxRp)} Path`; - const sep = `${'-'.repeat(maxOrg)} ${'-'.repeat(maxRp)} ${'-'.repeat(60)}`; - const rows = withSN - ? rps.map(r => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.serviceNames.join(', ')}`) - : rps.map(r => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.path}`); - return [header, sep, ...rows].join('\n'); - } + if (format === "json") return JSON.stringify(rps, null, 2); + if (rps.length === 0) + return `No resource providers ${withSN ? "with" : "without"} serviceNames found.`; + + if (format === "table") { + const maxOrg = Math.max(...rps.map((r) => r.orgName.length)); + const maxRp = Math.max(...rps.map((r) => r.rpNamespace.length)); + const header = withSN + ? `${"orgName".padEnd(maxOrg)} ${"rpNamespace".padEnd(maxRp)} serviceNames` + : `${"orgName".padEnd(maxOrg)} ${"rpNamespace".padEnd(maxRp)} Path`; + const sep = `${"-".repeat(maxOrg)} ${"-".repeat(maxRp)} ${"-".repeat(60)}`; + const rows = withSN + ? rps.map( + (r) => + `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.serviceNames.join(", ")}`, + ) + : rps.map( + (r) => + `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.path}`, + ); + return [header, sep, ...rows].join("\n"); + } + + return withSN + ? rps + .map( + (r) => + `${r.orgName}, ${r.rpNamespace}, [${r.serviceNames.join(", ")}]`, + ) + .join("\n") + : rps.map((r) => `${r.orgName}, ${r.rpNamespace}`).join("\n"); +} + +function printHelp() { + console.log(` +Fetch Azure resource providers with or without service names (service groups) + +Usage: + node fetch-resource-providers.js [options] + +Options: + --with-service-groups Include only RPs with serviceNames (service groups) + --format list|json|table Output format (default: list) + --count Output only the count + --output FILE Write output to file instead of stdout + --repo-root PATH Local repo root path (auto-detected if not provided) + --help, -h Show this help message - return withSN - ? rps.map(r => `${r.orgName}, ${r.rpNamespace}, [${r.serviceNames.join(', ')}]`).join('\n') - : rps.map(r => `${r.orgName}, ${r.rpNamespace}`).join('\n'); +Examples: + # RPs without service names + node fetch-resource-providers.js + + # RPs with service names + node fetch-resource-providers.js --with-service-groups + + # Output to file + node fetch-resource-providers.js --output rps.txt + + # JSON format + node fetch-resource-providers.js --format json +`); } function main() { - const args = { repoRoot: null, format: 'list', count: false, withSN: false, output: null }; - - for (let i = 2; i < process.argv.length; i++) { - const arg = process.argv[i]; - if (arg === '--help' || arg === '-h') { - console.log('Usage: node fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] [--output FILE] [--repo-root PATH]'); - return 0; - } - if (arg === '--repo-root') args.repoRoot = process.argv[++i]; - else if (arg === '--format') args.format = process.argv[++i]; - else if (arg === '--output' || arg === '-o') args.output = process.argv[++i]; - else if (arg === '--count') args.count = true; - else if (arg === '--with-service-groups') args.withSN = true; + const args = { + repoRoot: null, + format: "list", + count: false, + withSN: false, + output: null, + }; + + for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg === "--help" || arg === "-h") { + printHelp(); + return 0; + } + if (arg === "--repo-root") args.repoRoot = process.argv[++i]; + else if (arg === "--format") args.format = process.argv[++i]; + else if (arg === "--output" || arg === "-o") + args.output = process.argv[++i]; + else if (arg === "--count") args.count = true; + else if (arg === "--with-service-groups") args.withSN = true; + } + + try { + const repoRoot = args.repoRoot + ? path.resolve(args.repoRoot) + : findRepoRoot(); + const rps = findResourceProviders(repoRoot, args.withSN); + + let outputText; + if (args.count) { + outputText = String(rps.length); + } else { + outputText = formatOutput(rps, args.format, args.withSN); + if (args.format !== "json") { + outputText += `\n\nTotal: ${rps.length} resource provider(s) ${args.withSN ? "with" : "without"} serviceNames`; + } } - try { - const repoRoot = args.repoRoot ? path.resolve(args.repoRoot) : findRepoRoot(); - const rps = findResourceProviders(repoRoot, args.withSN); - - let outputText; - if (args.count) { - outputText = String(rps.length); - } else { - outputText = formatOutput(rps, args.format, args.withSN); - if (args.format !== 'json') { - outputText += `\n\nTotal: ${rps.length} resource provider(s) ${args.withSN ? 'with' : 'without'} serviceNames`; - } - } - - if (args.output) { - fs.writeFileSync(args.output, outputText + '\n'); - console.log(`Output written to ${args.output}`); - } else { - console.log(outputText); - } - return 0; - } catch (error) { - console.error(`Error: ${error.message}`); - return 1; + if (args.output) { + fs.writeFileSync(args.output, outputText + "\n"); + console.log(`Output written to ${args.output}`); + } else { + console.log(outputText); } + return 0; + } catch (error) { + console.error(`Error: ${error.message}`); + return 1; + } } -if (require.main === module) process.exit(main()); +if (require.main === module) { + process.exit(main()); +} -module.exports = { findRepoRoot, findResourceProviders, formatOutput, isServiceNameDirectory, hasVersionDirectories }; +module.exports = { + findRepoRoot, + findResourceProviders, + formatOutput, + isServiceNameDirectory, + hasVersionDirectories, +}; diff --git a/eng/scripts/generate-lease-files.js b/eng/scripts/generate-lease-files.js index b7edf52cd471..83b6e326b0f8 100644 --- a/eng/scripts/generate-lease-files.js +++ b/eng/scripts/generate-lease-files.js @@ -1,81 +1,84 @@ #!/usr/bin/env node // Generate lease.yaml files from resource provider data -const fs = require('fs'); -const path = require('path'); -const readline = require('readline'); +const fs = require("fs"); +const path = require("path"); +const readline = require("readline"); -const DEFAULT_DURATION = 'P180D'; -const LEASE_BASE_PATH = '.github/arm-leases'; +const DEFAULT_DURATION = "P180D"; +const LEASE_BASE_PATH = ".github/arm-leases"; function parseArgs() { - const args = { - input: null, - orgName: null, - rpNamespace: null, - serviceNames: [], - reviewer: null, - startdate: null, - duration: DEFAULT_DURATION, - repoRoot: null, - dryRun: false, - interactive: false, - }; - - for (let i = 2; i < process.argv.length; i++) { - const arg = process.argv[i]; - switch (arg) { - case '--help': - case '-h': - printHelp(); - process.exit(0); - case '--input': - args.input = process.argv[++i]; - break; - case '--orgName': - case '--service': - args.orgName = process.argv[++i]; - break; - case '--rpNamespace': - case '--resource-provider': - case '--rp': - args.rpNamespace = process.argv[++i]; - break; - case '--serviceName': - case '--service-groups': - case '--sg': - args.serviceNames = process.argv[++i].split(',').map(s => s.trim()).filter(Boolean); - break; - case '--reviewer': - args.reviewer = process.argv[++i]; - break; - case '--startdate': - args.startdate = process.argv[++i]; - break; - case '--duration': - args.duration = process.argv[++i]; - break; - case '--repo-root': - args.repoRoot = process.argv[++i]; - break; - case '--dry-run': - args.dryRun = true; - break; - case '--interactive': - case '-i': - args.interactive = true; - break; - default: - console.error(`Unknown argument: ${arg}`); - process.exit(1); - } + const args = { + input: null, + orgName: null, + rpNamespace: null, + serviceNames: [], + reviewer: null, + startdate: null, + duration: DEFAULT_DURATION, + repoRoot: null, + dryRun: false, + interactive: false, + }; + + for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + switch (arg) { + case "--help": + case "-h": + printHelp(); + process.exit(0); + case "--input": + args.input = process.argv[++i]; + break; + case "--orgName": + case "--service": + args.orgName = process.argv[++i]; + break; + case "--rpNamespace": + case "--resource-provider": + case "--rp": + args.rpNamespace = process.argv[++i]; + break; + case "--serviceName": + case "--service-groups": + case "--sg": + args.serviceNames = process.argv[++i] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + break; + case "--reviewer": + args.reviewer = process.argv[++i]; + break; + case "--startdate": + args.startdate = process.argv[++i]; + break; + case "--duration": + args.duration = process.argv[++i]; + break; + case "--repo-root": + args.repoRoot = process.argv[++i]; + break; + case "--dry-run": + args.dryRun = true; + break; + case "--interactive": + case "-i": + args.interactive = true; + break; + default: + console.error(`Unknown argument: ${arg}`); + process.exit(1); } + } - return args; + return args; } function printHelp() { - console.log(` + console.log(` Generate lease.yaml files for Azure Resource Providers Usage: @@ -145,339 +148,383 @@ Output: } function findRepoRoot(startPath = process.cwd()) { - let current = path.resolve(startPath); - for (let i = 0; i < 6; i++) { - if (fs.existsSync(path.join(current, 'specification'))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) break; - current = parent; + let current = path.resolve(startPath); + for (let i = 0; i < 6; i++) { + if (fs.existsSync(path.join(current, "specification"))) { + return current; } - throw new Error('Could not find repository root. Run from within azure-rest-api-specs.'); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + throw new Error( + "Could not find repository root. Run from within azure-rest-api-specs.", + ); } function validateStartDate(date) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { - throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD`); - } - const dateObj = new Date(date); - if (isNaN(dateObj.getTime())) { - throw new Error(`Invalid calendar date: ${date}`); - } - const gracePeriodDate = new Date(); - gracePeriodDate.setHours(0, 0, 0, 0); - gracePeriodDate.setDate(gracePeriodDate.getDate() - 10); - - if (dateObj < gracePeriodDate) { - throw new Error(`Startdate is too far in the past: ${date} (must be within 10 days of today)`); - } - - return date; + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD`); + } + const dateObj = new Date(date); + if (isNaN(dateObj.getTime())) { + throw new Error(`Invalid calendar date: ${date}`); + } + const gracePeriodDate = new Date(); + gracePeriodDate.setHours(0, 0, 0, 0); + gracePeriodDate.setDate(gracePeriodDate.getDate() - 10); + + if (dateObj < gracePeriodDate) { + throw new Error( + `Startdate is too far in the past: ${date} (must be within 10 days of today)`, + ); + } + + return date; } function validateDuration(duration) { - const match = duration.match(/^P(\d+)D$/i); - if (!match) { - throw new Error(`Invalid duration format: ${duration}. Expected P#D (e.g., P180D)`); - } - - const days = parseInt(match[1], 10); - if (days <= 0 || days > 180) { - throw new Error(`Duration must be between 1 and 180 days. Got: ${days}`); - } - - return duration.toUpperCase(); + const match = duration.match(/^P(\d+)D$/i); + if (!match) { + throw new Error( + `Invalid duration format: ${duration}. Expected P#D (e.g., P180D)`, + ); + } + + const days = parseInt(match[1], 10); + if (days <= 0 || days > 180) { + throw new Error(`Duration must be between 1 and 180 days. Got: ${days}`); + } + + return duration.toUpperCase(); } function validateRpNamespace(rpNamespace) { - const parts = rpNamespace.split('.'); - for (const part of parts) { - if (!/^[A-Z]/.test(part)) { - throw new Error(`rpNamespace parts must start with capital letter: ${rpNamespace}`); - } + const parts = rpNamespace.split("."); + for (const part of parts) { + if (!/^[A-Z]/.test(part)) { + throw new Error( + `rpNamespace parts must start with capital letter: ${rpNamespace}`, + ); } - return rpNamespace; + } + return rpNamespace; } function validateOrgName(orgName) { - if (!/^[a-z0-9]+$/.test(orgName)) { - throw new Error(`orgName must be lowercase alphanumeric: ${orgName}`); - } - return orgName; + if (!/^[a-z0-9]+$/.test(orgName)) { + throw new Error(`orgName must be lowercase alphanumeric: ${orgName}`); + } + return orgName; } function validateReviewer(reviewer) { - if (!reviewer || reviewer.trim().length === 0) { - throw new Error('Reviewer is required and cannot be empty'); - } - const trimmed = reviewer.trim(); - if (!trimmed.startsWith('@') || trimmed.length <= 1) { - throw new Error(`Reviewer must be a GitHub alias starting with @ (e.g., @githubUser). Got: ${reviewer}`); - } - return trimmed; + if (!reviewer || reviewer.trim().length === 0) { + throw new Error("Reviewer is required and cannot be empty"); + } + const trimmed = reviewer.trim(); + if (!trimmed.startsWith("@") || trimmed.length <= 1) { + throw new Error( + `Reviewer must be a GitHub alias starting with @ (e.g., @githubUser). Got: ${reviewer}`, + ); + } + return trimmed; } function parseInputLine(line) { - line = line.trim(); - if (!line || line.startsWith('#')) { - return null; - } - - const match = line.match(/^([^,]+),\s*([^,\[]+)(?:,\s*\[([^\]]+)\])?$/); - if (!match) { - console.warn(`Skipping invalid line: ${line}`); - return null; - } - - const orgName = match[1].trim(); - const rpNamespace = match[2].trim(); - const serviceNamesStr = match[3]; - const serviceNames = serviceNamesStr - ? serviceNamesStr.split(',').map(s => s.trim()).filter(Boolean) - : []; - - return { orgName, rpNamespace, serviceNames }; + line = line.trim(); + if (!line || line.startsWith("#")) { + return null; + } + + const match = line.match(/^([^,]+),\s*([^,\[]+)(?:,\s*\[([^\]]+)\])?$/); + if (!match) { + console.warn(`Skipping invalid line: ${line}`); + return null; + } + + const orgName = match[1].trim(); + const rpNamespace = match[2].trim(); + const serviceNamesStr = match[3]; + const serviceNames = serviceNamesStr + ? serviceNamesStr + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + return { orgName, rpNamespace, serviceNames }; } function generateLeaseYaml(rpNamespace, startdate, duration, reviewer) { - return `lease: + return `lease: resource-provider: ${rpNamespace} - startdate: ${startdate} + startdate: "${startdate}" duration: ${duration} - reviewer: ${reviewer} + reviewer: "${reviewer}" + `; } function getLeasePath(repoRoot, orgName, rpNamespace, serviceName = null) { - const basePath = path.join(repoRoot, LEASE_BASE_PATH, orgName, rpNamespace); - if (serviceName) { - return path.join(basePath, serviceName, 'lease.yaml'); - } - return path.join(basePath, 'lease.yaml'); + const basePath = path.join(repoRoot, LEASE_BASE_PATH, orgName, rpNamespace); + if (serviceName) { + return path.join(basePath, serviceName, "lease.yaml"); + } + return path.join(basePath, "lease.yaml"); } function createLeaseFile(filePath, content, dryRun = false) { - if (dryRun) { - console.log(`[DRY RUN] Would create: ${filePath}`); - console.log(content); - console.log('---'); - return; - } - - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - if (fs.existsSync(filePath)) { - console.warn(`Warning: File already exists, skipping: ${filePath}`); - return; - } - - fs.writeFileSync(filePath, content, 'utf-8'); - console.log(`Created: ${filePath}`); + if (dryRun) { + console.log(`[DRY RUN] Would create: ${filePath}`); + console.log(content); + console.log("---"); + return; + } + + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(filePath)) { + console.warn(`Warning: File already exists, skipping: ${filePath}`); + return; + } + + fs.writeFileSync(filePath, content, "utf-8"); + console.log(`Created: ${filePath}`); } function processEntry(entry, args, repoRoot) { - const { orgName, rpNamespace, serviceNames } = entry; - const { startdate, duration, reviewer, dryRun } = args; - - try { - validateOrgName(orgName); - validateRpNamespace(rpNamespace); - - const content = generateLeaseYaml(rpNamespace, startdate, duration, reviewer); - - if (serviceNames.length === 0) { - const leasePath = getLeasePath(repoRoot, orgName, rpNamespace); - createLeaseFile(leasePath, content, dryRun); - } else { - for (const serviceName of serviceNames) { - const leasePath = getLeasePath(repoRoot, orgName, rpNamespace, serviceName); - createLeaseFile(leasePath, content, dryRun); - } - } - } catch (error) { - console.error(`Error processing ${orgName}/${rpNamespace}: ${error.message}`); + const { orgName, rpNamespace, serviceNames } = entry; + const { startdate, duration, reviewer, dryRun } = args; + + try { + validateOrgName(orgName); + validateRpNamespace(rpNamespace); + + const content = generateLeaseYaml( + rpNamespace, + startdate, + duration, + reviewer, + ); + + if (serviceNames.length === 0) { + const leasePath = getLeasePath(repoRoot, orgName, rpNamespace); + createLeaseFile(leasePath, content, dryRun); + } else { + for (const serviceName of serviceNames) { + const leasePath = getLeasePath( + repoRoot, + orgName, + rpNamespace, + serviceName, + ); + createLeaseFile(leasePath, content, dryRun); + } } + } catch (error) { + console.error( + `Error processing ${orgName}/${rpNamespace}: ${error.message}`, + ); + } } async function promptInteractive() { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - const question = (prompt) => new Promise((resolve) => { - rl.question(prompt, resolve); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const question = (prompt) => + new Promise((resolve) => { + rl.question(prompt, resolve); }); - console.log('\nInteractive Lease File Generator'); - console.log('=================================\n'); - - const reviewer = await question('Enter reviewer GitHub alias (required, e.g., @githubUser): '); - if (!reviewer.trim()) { - console.error('Error: Reviewer name is required'); - rl.close(); - process.exit(1); - } - - const startdateInput = await question(`Enter start date [YYYY-MM-DD] (default: ${getTodayDate()}): `); - const startdate = startdateInput.trim() || getTodayDate(); - - const durationInput = await question(`Enter duration [P#D] (default: ${DEFAULT_DURATION}): `); - const duration = durationInput.trim() || DEFAULT_DURATION; + console.log("\nInteractive Lease File Generator"); + console.log("=================================\n"); - console.log('\nEnter resource provider entries (one per line, empty line to finish):'); - console.log('Format: orgName, rpNamespace, [optional serviceNames]'); - console.log('Example: storage, Microsoft.Storage'); - console.log('Example: compute, Microsoft.Compute, [ComputeRP, DiskRP]\n'); - - const entries = []; - while (true) { - const line = await question('> '); - if (!line.trim()) break; - - const entry = parseInputLine(line); - if (entry) { - entries.push(entry); - } + const reviewer = await question( + "Enter reviewer GitHub alias (required, e.g., @githubUser): ", + ); + if (!reviewer.trim()) { + console.error("Error: Reviewer name is required"); + rl.close(); + process.exit(1); + } + + const startdateInput = await question( + `Enter start date [YYYY-MM-DD] (default: ${getTodayDate()}): `, + ); + const startdate = startdateInput.trim() || getTodayDate(); + + const durationInput = await question( + `Enter duration [P#D] (default: ${DEFAULT_DURATION}): `, + ); + const duration = durationInput.trim() || DEFAULT_DURATION; + + console.log( + "\nEnter resource provider entries (one per line, empty line to finish):", + ); + console.log("Format: orgName, rpNamespace, [optional serviceNames]"); + console.log("Example: storage, Microsoft.Storage"); + console.log("Example: compute, Microsoft.Compute, [ComputeRP, DiskRP]\n"); + + const entries = []; + while (true) { + const line = await question("> "); + if (!line.trim()) break; + + const entry = parseInputLine(line); + if (entry) { + entries.push(entry); } + } - rl.close(); + rl.close(); - return { - reviewer: reviewer.trim(), - startdate, - duration, - entries, - dryRun: false, - }; + return { + reviewer: reviewer.trim(), + startdate, + duration, + entries, + dryRun: false, + }; } function getTodayDate() { - const today = new Date(); - return today.toISOString().split('T')[0]; + const today = new Date(); + return today.toISOString().split("T")[0]; } async function main() { - let args = parseArgs(); + let args = parseArgs(); - if (args.interactive) { - const interactive = await promptInteractive(); - args.reviewer = interactive.reviewer; - args.startdate = interactive.startdate; - args.duration = interactive.duration; + if (args.interactive) { + const interactive = await promptInteractive(); + args.reviewer = interactive.reviewer; + args.startdate = interactive.startdate; + args.duration = interactive.duration; - if (interactive.entries.length === 0) { - console.error('Error: No entries provided'); - return 1; - } - - try { - const repoRoot = args.repoRoot ? path.resolve(args.repoRoot) : findRepoRoot(); - const reviewer = validateReviewer(args.reviewer); - const startdate = validateStartDate(args.startdate); - const duration = validateDuration(args.duration); - - console.log(`\nRepository root: ${repoRoot}`); - console.log(`Reviewer: ${reviewer}`); - console.log(`Start date: ${startdate}`); - console.log(`Duration: ${duration}\n`); - - for (const entry of interactive.entries) { - processEntry(entry, { ...args, startdate, duration, reviewer }, repoRoot); - } - - console.log(`\nProcessed ${interactive.entries.length} entries`); - return 0; - } catch (error) { - console.error(`Error: ${error.message}`); - return 1; - } + if (interactive.entries.length === 0) { + console.error("Error: No entries provided"); + return 1; } - if (!args.reviewer) { - console.error('Error: --reviewer is required'); - console.error('Use --help for usage information'); - return 1; + try { + const repoRoot = args.repoRoot + ? path.resolve(args.repoRoot) + : findRepoRoot(); + const reviewer = validateReviewer(args.reviewer); + const startdate = validateStartDate(args.startdate); + const duration = validateDuration(args.duration); + + console.log(`\nRepository root: ${repoRoot}`); + console.log(`Reviewer: ${reviewer}`); + console.log(`Start date: ${startdate}`); + console.log(`Duration: ${duration}\n`); + + for (const entry of interactive.entries) { + processEntry( + entry, + { ...args, startdate, duration, reviewer }, + repoRoot, + ); + } + + console.log(`\nProcessed ${interactive.entries.length} entries`); + return 0; + } catch (error) { + console.error(`Error: ${error.message}`); + return 1; } + } + + if (!args.reviewer) { + console.error("Error: --reviewer is required"); + console.error("Use --help for usage information"); + return 1; + } + + if (!args.startdate) { + args.startdate = getTodayDate(); + } + + try { + const repoRoot = args.repoRoot + ? path.resolve(args.repoRoot) + : findRepoRoot(); + const reviewer = validateReviewer(args.reviewer); + const startdate = validateStartDate(args.startdate); + const duration = validateDuration(args.duration); + + console.log(`Repository root: ${repoRoot}`); + console.log(`Reviewer: ${reviewer}`); + console.log(`Start date: ${startdate}`); + console.log(`Duration: ${duration}\n`); - if (!args.startdate) { - args.startdate = getTodayDate(); - } + const entries = []; - try { - const repoRoot = args.repoRoot ? path.resolve(args.repoRoot) : findRepoRoot(); - const reviewer = validateReviewer(args.reviewer); - const startdate = validateStartDate(args.startdate); - const duration = validateDuration(args.duration); - - console.log(`Repository root: ${repoRoot}`); - console.log(`Reviewer: ${reviewer}`); - console.log(`Start date: ${startdate}`); - console.log(`Duration: ${duration}\n`); - - const entries = []; - - if (args.input) { - const inputPath = path.resolve(args.input); - if (!fs.existsSync(inputPath)) { - throw new Error(`Input file not found: ${inputPath}`); - } - - const content = fs.readFileSync(inputPath, 'utf-8'); - const lines = content.split('\n'); - - for (const line of lines) { - const entry = parseInputLine(line); - if (entry) { - entries.push(entry); - } - } - - if (entries.length === 0) { - console.warn('Warning: No valid entries found in input file'); - return 0; - } - } else if (args.orgName && args.rpNamespace) { - entries.push({ - orgName: args.orgName, - rpNamespace: args.rpNamespace, - serviceNames: args.serviceNames, - }); - } else { - console.error('Error: Either --input or both --orgName and --rpNamespace are required'); - console.error('Use --help for usage information'); - return 1; - } + if (args.input) { + const inputPath = path.resolve(args.input); + if (!fs.existsSync(inputPath)) { + throw new Error(`Input file not found: ${inputPath}`); + } + + const content = fs.readFileSync(inputPath, "utf-8"); + const lines = content.split("\n"); - for (const entry of entries) { - processEntry(entry, { ...args, startdate, duration, reviewer }, repoRoot); + for (const line of lines) { + const entry = parseInputLine(line); + if (entry) { + entries.push(entry); } + } - console.log(`\nProcessed ${entries.length} entries`); + if (entries.length === 0) { + console.warn("Warning: No valid entries found in input file"); return 0; + } + } else if (args.orgName && args.rpNamespace) { + entries.push({ + orgName: args.orgName, + rpNamespace: args.rpNamespace, + serviceNames: args.serviceNames, + }); + } else { + console.error( + "Error: Either --input or both --orgName and --rpNamespace are required", + ); + console.error("Use --help for usage information"); + return 1; + } - } catch (error) { - console.error(`Error: ${error.message}`); - return 1; + for (const entry of entries) { + processEntry(entry, { ...args, startdate, duration, reviewer }, repoRoot); } + + console.log(`\nProcessed ${entries.length} entries`); + return 0; + } catch (error) { + console.error(`Error: ${error.message}`); + return 1; + } } if (require.main === module) { - main().then(code => process.exit(code)); + main().then((code) => process.exit(code)); } module.exports = { - parseInputLine, - generateLeaseYaml, - getLeasePath, - validateStartDate, - validateDuration, - validateRpNamespace, - validateOrgName, - validateReviewer, - getTodayDate, + parseInputLine, + generateLeaseYaml, + getLeasePath, + validateStartDate, + validateDuration, + validateRpNamespace, + validateOrgName, + validateReviewer, + getTodayDate, }; From 2ed82236a3c97d1723fdecc2056d00be5392ba47 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Thu, 30 Apr 2026 18:44:00 -0500 Subject: [PATCH 06/18] Moved under arm-leases --- .github/arm-leases/README.md | 24 +++++++++---------- .../scripts/fetch-resource-providers.js | 0 .../scripts/generate-lease-files.js | 0 .../tests}/fetch-resource-providers.test.js | 0 .../tests}/generate-lease-files.test.js | 0 .gitignore | 1 - 6 files changed, 12 insertions(+), 13 deletions(-) rename {eng => .github/arm-leases}/scripts/fetch-resource-providers.js (100%) rename {eng => .github/arm-leases}/scripts/generate-lease-files.js (100%) rename {eng/scripts/Tests => .github/arm-leases/scripts/tests}/fetch-resource-providers.test.js (100%) rename {eng/scripts/Tests => .github/arm-leases/scripts/tests}/generate-lease-files.test.js (100%) diff --git a/.github/arm-leases/README.md b/.github/arm-leases/README.md index fab306e28ea2..808d11c8e0f4 100644 --- a/.github/arm-leases/README.md +++ b/.github/arm-leases/README.md @@ -98,7 +98,7 @@ If your PR check **"ARM Lease Validation"** is failing, review the error message ## Automation Scripts -Scripts are located in `eng/scripts/`. Run from the repository root. +Scripts are located in `.github/arm-leases/scripts/`. Run from the repository root. ### Single Resource Provider @@ -106,20 +106,20 @@ Scripts are located in `eng/scripts/`. Run from the repository root. ```bash # Without service groups -node eng/scripts/generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration +node .github/arm-leases/scripts/generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration # With service groups -node eng/scripts/generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration --serviceName "," +node .github/arm-leases/scripts/generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration --serviceName "," ``` **Examples:** ```bash # Without service groups -node eng/scripts/generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" --startdate 2026-05-01 --duration P90D +node .github/arm-leases/scripts/generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" --startdate 2026-05-01 --duration P90D # With service groups -node eng/scripts/generate-lease-files.js --orgName compute --rpNamespace Microsoft.Compute --reviewer "@janesmith" --startdate 2026-05-01 --duration P180D --serviceName "DiskRP,ComputeRP,GalleryRP" +node .github/arm-leases/scripts/generate-lease-files.js --orgName compute --rpNamespace Microsoft.Compute --reviewer "@janesmith" --startdate 2026-05-01 --duration P180D --serviceName "DiskRP,ComputeRP,GalleryRP" ``` ### Bulk Generation @@ -128,23 +128,23 @@ node eng/scripts/generate-lease-files.js --orgName compute --rpNamespace Microso ```bash # Generate resource provider list (without service groups) -node eng/scripts/fetch-resource-providers.js --output +node .github/arm-leases/scripts/fetch-resource-providers.js --output # Generate resource provider list (with service groups) -node eng/scripts/fetch-resource-providers.js --with-service-groups --output +node .github/arm-leases/scripts/fetch-resource-providers.js --with-service-groups --output # Generate lease files from list -node eng/scripts/generate-lease-files.js --input --reviewer "@youralias" --startdate --duration +node .github/arm-leases/scripts/generate-lease-files.js --input --reviewer "@youralias" --startdate --duration ``` **Examples:** ```bash # RPs without service groups (e.g., Microsoft.Storage) -node eng/scripts/fetch-resource-providers.js --output rps-simple.txt -node eng/scripts/generate-lease-files.js --input rps-simple.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D +node .github/arm-leases/scripts/fetch-resource-providers.js --output rps-simple.txt +node .github/arm-leases/scripts/generate-lease-files.js --input rps-simple.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D # RPs with service groups (e.g., Microsoft.Compute with DiskRP, ComputeRP) -node eng/scripts/fetch-resource-providers.js --with-service-groups --output rps-groups.txt -node eng/scripts/generate-lease-files.js --input rps-groups.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D +node .github/arm-leases/scripts/fetch-resource-providers.js --with-service-groups --output rps-groups.txt +node .github/arm-leases/scripts/generate-lease-files.js --input rps-groups.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D ``` diff --git a/eng/scripts/fetch-resource-providers.js b/.github/arm-leases/scripts/fetch-resource-providers.js similarity index 100% rename from eng/scripts/fetch-resource-providers.js rename to .github/arm-leases/scripts/fetch-resource-providers.js diff --git a/eng/scripts/generate-lease-files.js b/.github/arm-leases/scripts/generate-lease-files.js similarity index 100% rename from eng/scripts/generate-lease-files.js rename to .github/arm-leases/scripts/generate-lease-files.js diff --git a/eng/scripts/Tests/fetch-resource-providers.test.js b/.github/arm-leases/scripts/tests/fetch-resource-providers.test.js similarity index 100% rename from eng/scripts/Tests/fetch-resource-providers.test.js rename to .github/arm-leases/scripts/tests/fetch-resource-providers.test.js diff --git a/eng/scripts/Tests/generate-lease-files.test.js b/.github/arm-leases/scripts/tests/generate-lease-files.test.js similarity index 100% rename from eng/scripts/Tests/generate-lease-files.test.js rename to .github/arm-leases/scripts/tests/generate-lease-files.test.js diff --git a/.gitignore b/.gitignore index aa42a060cf3f..43bcc525987a 100644 --- a/.gitignore +++ b/.gitignore @@ -120,7 +120,6 @@ warnings.txt # Blanket ignores *.js !.github/**/*.js -!eng/scripts/**/*.js *.d.ts *.js.map *.d.ts.map From 4b0a984e744cbf243112db21ccc5f51dc2dec01a Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Thu, 30 Apr 2026 18:50:11 -0500 Subject: [PATCH 07/18] Exclude script file from arm-lease validation --- .../workflows/src/arm-lease-validation/arm-lease-validation.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/src/arm-lease-validation/arm-lease-validation.js b/.github/workflows/src/arm-lease-validation/arm-lease-validation.js index 08dbef01a837..7f543dcb438c 100644 --- a/.github/workflows/src/arm-lease-validation/arm-lease-validation.js +++ b/.github/workflows/src/arm-lease-validation/arm-lease-validation.js @@ -20,6 +20,7 @@ export const ALLOWED_FILE_PATTERNS = [ LEASE_FILE_PATTERN, LEASE_FILE_WITH_GROUP_PATTERN, /^\.github\/arm-leases\/README\.md$/, + /^\.github\/arm-leases\/scripts\/.+$/, ]; /** From 5e54a879c3608e954d6c9fba428e0a146e58c8b3 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Thu, 30 Apr 2026 19:25:50 -0500 Subject: [PATCH 08/18] Add test for scripts folder allowance --- .../test/arm-lease-validation/arm-lease-validation.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js index a034e03e63d2..8d635f9d8fc0 100644 --- a/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js @@ -31,6 +31,13 @@ describe("validate-arm-leases", () => { expect(isFileAllowed(".github/arm-leases/README.md")).toBe(true); }); + it("allows script files under scripts folder", () => { + expect(isFileAllowed(".github/arm-leases/scripts/fetch-resource-providers.js")).toBe(true); + expect(isFileAllowed(".github/arm-leases/scripts/generate-lease-files.js")).toBe(true); + expect(isFileAllowed(".github/arm-leases/scripts/tests/fetch-resource-providers.test.js")).toBe(true); + expect(isFileAllowed(".github/arm-leases/scripts/tests/generate-lease-files.test.js")).toBe(true); + }); + it("rejects invalid files", () => { expect(isFileAllowed(".github/arm-leases/anything/here")).toBe(false); expect(isFileAllowed(".github/arm-leases/testservice/Microsoft.Test/other.yaml")).toBe(false); From 4051262cb38d4794e7a954a9dfe1a6cd4b976f21 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Thu, 30 Apr 2026 19:29:30 -0500 Subject: [PATCH 09/18] Exclude scripts folder from non-lease file check --- .../src/arm-lease-validation/arm-lease-validation.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/src/arm-lease-validation/arm-lease-validation.js b/.github/workflows/src/arm-lease-validation/arm-lease-validation.js index 7f543dcb438c..8ac4079cc311 100644 --- a/.github/workflows/src/arm-lease-validation/arm-lease-validation.js +++ b/.github/workflows/src/arm-lease-validation/arm-lease-validation.js @@ -262,7 +262,10 @@ export default async function validateArmLeases(core) { // Check for non-lease.yaml and non-README files core.startGroup("Checking for non-lease files"); const nonLeaseFiles = allChangedFiles.filter( - (file) => !file.endsWith("/lease.yaml") && !file.endsWith("/README.md"), + (file) => + !file.endsWith("/lease.yaml") && + !file.endsWith("/README.md") && + !file.startsWith(".github/arm-leases/scripts/"), ); if (nonLeaseFiles.length > 0) { From 3af4dfbac929b8fc50e1aefbcda19700d9fe14ae Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Thu, 30 Apr 2026 19:33:24 -0500 Subject: [PATCH 10/18] Exclude scripts folder from folder structure validation --- .../src/arm-lease-validation/arm-lease-validation.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/src/arm-lease-validation/arm-lease-validation.js b/.github/workflows/src/arm-lease-validation/arm-lease-validation.js index 8ac4079cc311..ec98a3ca3ae1 100644 --- a/.github/workflows/src/arm-lease-validation/arm-lease-validation.js +++ b/.github/workflows/src/arm-lease-validation/arm-lease-validation.js @@ -282,7 +282,10 @@ export default async function validateArmLeases(core) { // Get ARM lease files (only lease.yaml files) const armLeaseFiles = allChangedFiles.filter( - (file) => file.startsWith(".github/arm-leases/") && !file.endsWith(".md"), + (file) => + file.startsWith(".github/arm-leases/") && + !file.endsWith(".md") && + !file.startsWith(".github/arm-leases/scripts/"), ); if (armLeaseFiles.length === 0) { From c1723c83833a0d1d8a8c78847d7a3c1cd3c121a7 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Thu, 30 Apr 2026 19:45:37 -0500 Subject: [PATCH 11/18] Convert arm-lease scripts to CJS and tests to vitest format --- .github/arm-leases/README.md | 22 +- ...viders.js => fetch-resource-providers.cjs} | 0 ...ease-files.js => generate-lease-files.cjs} | 0 .../tests/fetch-resource-providers.test.js | 169 -------- .../tests/generate-lease-files.test.js | 370 ------------------ .../fetch-resource-providers.test.js | 88 +++++ .../generate-lease-files.test.js | 179 +++++++++ .../arm-lease-validation.test.js | 6 +- 8 files changed, 280 insertions(+), 554 deletions(-) rename .github/arm-leases/scripts/{fetch-resource-providers.js => fetch-resource-providers.cjs} (100%) rename .github/arm-leases/scripts/{generate-lease-files.js => generate-lease-files.cjs} (100%) delete mode 100644 .github/arm-leases/scripts/tests/fetch-resource-providers.test.js delete mode 100644 .github/arm-leases/scripts/tests/generate-lease-files.test.js create mode 100644 .github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js create mode 100644 .github/workflows/test/arm-lease-scripts/generate-lease-files.test.js diff --git a/.github/arm-leases/README.md b/.github/arm-leases/README.md index 808d11c8e0f4..26fe9d713021 100644 --- a/.github/arm-leases/README.md +++ b/.github/arm-leases/README.md @@ -106,20 +106,20 @@ Scripts are located in `.github/arm-leases/scripts/`. Run from the repository ro ```bash # Without service groups -node .github/arm-leases/scripts/generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration +node .github/arm-leases/scripts/generate-lease-files.cjs --orgName --rpNamespace --reviewer "@youralias" --startdate --duration # With service groups -node .github/arm-leases/scripts/generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration --serviceName "," +node .github/arm-leases/scripts/generate-lease-files.cjs --orgName --rpNamespace --reviewer "@youralias" --startdate --duration --serviceName "," ``` **Examples:** ```bash # Without service groups -node .github/arm-leases/scripts/generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" --startdate 2026-05-01 --duration P90D +node .github/arm-leases/scripts/generate-lease-files.cjs --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" --startdate 2026-05-01 --duration P90D # With service groups -node .github/arm-leases/scripts/generate-lease-files.js --orgName compute --rpNamespace Microsoft.Compute --reviewer "@janesmith" --startdate 2026-05-01 --duration P180D --serviceName "DiskRP,ComputeRP,GalleryRP" +node .github/arm-leases/scripts/generate-lease-files.cjs --orgName compute --rpNamespace Microsoft.Compute --reviewer "@janesmith" --startdate 2026-05-01 --duration P180D --serviceName "DiskRP,ComputeRP,GalleryRP" ``` ### Bulk Generation @@ -128,23 +128,23 @@ node .github/arm-leases/scripts/generate-lease-files.js --orgName compute --rpNa ```bash # Generate resource provider list (without service groups) -node .github/arm-leases/scripts/fetch-resource-providers.js --output +node .github/arm-leases/scripts/fetch-resource-providers.cjs --output # Generate resource provider list (with service groups) -node .github/arm-leases/scripts/fetch-resource-providers.js --with-service-groups --output +node .github/arm-leases/scripts/fetch-resource-providers.cjs --with-service-groups --output # Generate lease files from list -node .github/arm-leases/scripts/generate-lease-files.js --input --reviewer "@youralias" --startdate --duration +node .github/arm-leases/scripts/generate-lease-files.cjs --input --reviewer "@youralias" --startdate --duration ``` **Examples:** ```bash # RPs without service groups (e.g., Microsoft.Storage) -node .github/arm-leases/scripts/fetch-resource-providers.js --output rps-simple.txt -node .github/arm-leases/scripts/generate-lease-files.js --input rps-simple.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D +node .github/arm-leases/scripts/fetch-resource-providers.cjs --output rps-simple.txt +node .github/arm-leases/scripts/generate-lease-files.cjs --input rps-simple.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D # RPs with service groups (e.g., Microsoft.Compute with DiskRP, ComputeRP) -node .github/arm-leases/scripts/fetch-resource-providers.js --with-service-groups --output rps-groups.txt -node .github/arm-leases/scripts/generate-lease-files.js --input rps-groups.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D +node .github/arm-leases/scripts/fetch-resource-providers.cjs --with-service-groups --output rps-groups.txt +node .github/arm-leases/scripts/generate-lease-files.cjs --input rps-groups.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D ``` diff --git a/.github/arm-leases/scripts/fetch-resource-providers.js b/.github/arm-leases/scripts/fetch-resource-providers.cjs similarity index 100% rename from .github/arm-leases/scripts/fetch-resource-providers.js rename to .github/arm-leases/scripts/fetch-resource-providers.cjs diff --git a/.github/arm-leases/scripts/generate-lease-files.js b/.github/arm-leases/scripts/generate-lease-files.cjs similarity index 100% rename from .github/arm-leases/scripts/generate-lease-files.js rename to .github/arm-leases/scripts/generate-lease-files.cjs diff --git a/.github/arm-leases/scripts/tests/fetch-resource-providers.test.js b/.github/arm-leases/scripts/tests/fetch-resource-providers.test.js deleted file mode 100644 index 2e9d7f9fae17..000000000000 --- a/.github/arm-leases/scripts/tests/fetch-resource-providers.test.js +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env node -// Tests for fetch-resource-providers.js - -const path = require("path"); -const { - findRepoRoot, - findResourceProviders, - formatOutput, -} = require("../fetch-resource-providers.js"); - -function assert(condition, message) { - if (!condition) throw new Error(`Test failed: ${message}`); -} - -function testFindRepoRoot() { - const repoRoot = findRepoRoot(); - assert( - repoRoot.endsWith("azure-rest-api-specs"), - "Repo root should end with azure-rest-api-specs", - ); - assert( - require("fs").existsSync(path.join(repoRoot, "specification")), - "Specification directory should exist", - ); - console.log("✓ findRepoRoot test passed"); -} - -function testFindResourceProvidersWithout() { - const repoRoot = findRepoRoot(); - const rps = findResourceProviders(repoRoot, false); - assert( - rps.length > 0, - "Should find resource providers without service names", - ); - assert( - rps.every((rp) => rp.rpNamespace && rp.orgName && rp.path), - "All RPs should have rpNamespace, orgName, and path", - ); - assert( - rps.every((rp) => !rp.serviceNames), - "RPs without service names should not have serviceNames field", - ); - assert( - rps.some((rp) => rp.rpNamespace === "Microsoft.Storage"), - "Should include Microsoft.Storage", - ); - assert( - !rps.some((rp) => rp.rpNamespace === "Microsoft.Compute"), - "Should not include Microsoft.Compute (has service names)", - ); - console.log( - `✓ findResourceProviders (without) test passed - found ${rps.length} RPs`, - ); -} - -function testFindResourceProvidersWith() { - const repoRoot = findRepoRoot(); - const rps = findResourceProviders(repoRoot, true); - assert(rps.length > 0, "Should find resource providers with service names"); - assert( - rps.every( - (rp) => rp.rpNamespace && rp.orgName && rp.path && rp.serviceNames, - ), - "All RPs should have rpNamespace, orgName, path, and serviceNames", - ); - assert( - rps.every((rp) => Array.isArray(rp.serviceNames)), - "serviceNames should be an array", - ); - const compute = rps.find((rp) => rp.rpNamespace === "Microsoft.Compute"); - assert(compute, "Should include Microsoft.Compute"); - assert( - compute.orgName === "compute", - 'Microsoft.Compute orgName should be "compute"', - ); - assert( - compute.serviceNames.includes("Compute"), - "Microsoft.Compute should have Compute service name", - ); - assert( - !rps.some((rp) => rp.rpNamespace === "Microsoft.Storage"), - "Should not include Microsoft.Storage (no service names)", - ); - console.log( - `✓ findResourceProviders (with) test passed - found ${rps.length} RPs`, - ); -} - -function testFormatOutputList() { - const rps = [ - { rpNamespace: "Microsoft.Test", orgName: "test", path: "test/path" }, - { - rpNamespace: "Microsoft.Test2", - orgName: "test2", - path: "test2/path", - serviceNames: ["Group1", "Group2"], - }, - ]; - - const outputWithout = formatOutput([rps[0]], "list", false); - assert( - outputWithout.includes("test, Microsoft.Test"), - "List format should include orgName and rpNamespace", - ); - - const outputWith = formatOutput([rps[1]], "list", true); - assert( - outputWith.includes("test2, Microsoft.Test2, [Group1, Group2]"), - "List format with serviceNames should include orgName, rpNamespace, and serviceNames", - ); - - console.log("✓ formatOutput (list) test passed"); -} - -function testFormatOutputJson() { - const rps = [ - { rpNamespace: "Microsoft.Test", orgName: "test", path: "test/path" }, - ]; - const output = formatOutput(rps, "json", false); - const parsed = JSON.parse(output); - assert(Array.isArray(parsed), "JSON output should be an array"); - assert( - parsed[0].rpNamespace === "Microsoft.Test", - "JSON should preserve RP namespace", - ); - console.log("✓ formatOutput (json) test passed"); -} - -function testFormatOutputTable() { - const rps = [ - { rpNamespace: "Microsoft.Test", orgName: "test", path: "test/path" }, - ]; - const output = formatOutput(rps, "table", false); - assert(output.includes("orgName"), "Table should have orgName header"); - assert( - output.includes("rpNamespace"), - "Table should have rpNamespace header", - ); - assert(output.includes("test"), "Table should include org name"); - assert( - output.includes("Microsoft.Test"), - "Table should include RP namespace", - ); - console.log("✓ formatOutput (table) test passed"); -} - -function runTests() { - console.log("Running tests for fetch-resource-providers.js...\n"); - - try { - testFindRepoRoot(); - testFindResourceProvidersWithout(); - testFindResourceProvidersWith(); - testFormatOutputList(); - testFormatOutputJson(); - testFormatOutputTable(); - - console.log("\n✅ All tests passed!"); - return 0; - } catch (error) { - console.error(`\n❌ ${error.message}`); - console.error(error.stack); - return 1; - } -} - -if (require.main === module) { - process.exit(runTests()); -} diff --git a/.github/arm-leases/scripts/tests/generate-lease-files.test.js b/.github/arm-leases/scripts/tests/generate-lease-files.test.js deleted file mode 100644 index c4cab450a9ad..000000000000 --- a/.github/arm-leases/scripts/tests/generate-lease-files.test.js +++ /dev/null @@ -1,370 +0,0 @@ -#!/usr/bin/env node -// Tests for generate-lease-files.js - -const path = require("path"); -const fs = require("fs"); -const { - parseInputLine, - generateLeaseYaml, - getLeasePath, - validateStartDate, - validateDuration, - validateRpNamespace, - validateOrgName, - validateReviewer, - getTodayDate, -} = require("../generate-lease-files.js"); - -function assert(condition, message) { - if (!condition) throw new Error(`Test failed: ${message}`); -} - -function testParseInputLine() { - // Test without service names - const result1 = parseInputLine("storage, Microsoft.Storage"); - assert(result1.orgName === "storage", "Org name should be storage"); - assert( - result1.rpNamespace === "Microsoft.Storage", - "RP should be Microsoft.Storage", - ); - assert(result1.serviceNames.length === 0, "Should have no service names"); - - // Test with service names - const result2 = parseInputLine( - "compute, Microsoft.Compute, [ComputeRP, DiskRP]", - ); - assert(result2.orgName === "compute", "Org name should be compute"); - assert( - result2.rpNamespace === "Microsoft.Compute", - "RP should be Microsoft.Compute", - ); - assert(result2.serviceNames.length === 2, "Should have 2 service names"); - assert( - result2.serviceNames[0] === "ComputeRP", - "First name should be ComputeRP", - ); - assert(result2.serviceNames[1] === "DiskRP", "Second name should be DiskRP"); - - // Test empty line - const result3 = parseInputLine(""); - assert(result3 === null, "Empty line should return null"); - - // Test comment - const result4 = parseInputLine("# This is a comment"); - assert(result4 === null, "Comment should return null"); - - console.log("✓ parseInputLine tests passed"); -} - -function testGenerateLeaseYaml() { - const yaml = generateLeaseYaml( - "Microsoft.Test", - "2026-06-01", - "P180D", - "@johnDoe", - ); - - assert( - yaml.includes("resource-provider: Microsoft.Test"), - "Should include resource provider", - ); - assert(yaml.includes('startdate: "2026-06-01"'), "Should include startdate with quotes"); - assert( - yaml.includes("duration: P180D"), - "Should include duration (not duration-days)", - ); - assert( - !yaml.includes("duration-days:"), - "Should NOT use duration-days field", - ); - assert(yaml.includes('reviewer: "@johnDoe"'), "Should include reviewer with quotes"); - assert(yaml.endsWith("\n\n"), "Should have trailing blank line (6th line)"); - - console.log("✓ generateLeaseYaml tests passed"); -} - -function testGetLeasePath() { - const repoRoot = "/repo"; - - // Without service name (use path.join for cross-platform) - const path1 = getLeasePath(repoRoot, "storage", "Microsoft.Storage"); - const expected1 = path.join( - repoRoot, - ".github", - "arm-leases", - "storage", - "Microsoft.Storage", - "lease.yaml", - ); - assert(path1 === expected1, "Path without service name should be correct"); - - // With service name - const path2 = getLeasePath( - repoRoot, - "compute", - "Microsoft.Compute", - "DiskRP", - ); - const expected2 = path.join( - repoRoot, - ".github", - "arm-leases", - "compute", - "Microsoft.Compute", - "DiskRP", - "lease.yaml", - ); - assert(path2 === expected2, "Path with service name should be correct"); - - console.log("✓ getLeasePath tests passed"); -} - -function testValidateStartDate() { - const today = getTodayDate(); - - // Valid date (today) - try { - validateStartDate(today); - console.log(" ✓ Today date is valid"); - } catch (error) { - throw new Error("Today should be valid"); - } - - // Valid date (future) - try { - validateStartDate("2027-12-31"); - console.log(" ✓ Future date is valid"); - } catch (error) { - throw new Error("Future date should be valid"); - } - - // Valid date (within grace period - 5 days ago) - const fiveDaysAgo = new Date(); - fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5); - const fiveDaysAgoStr = fiveDaysAgo.toISOString().split("T")[0]; - try { - validateStartDate(fiveDaysAgoStr); - console.log(" ✓ Date within 10-day grace period is valid"); - } catch (error) { - throw new Error( - `Date within 10-day grace period should be valid: ${error.message}`, - ); - } - - // Invalid format - try { - validateStartDate("12/31/2026"); - throw new Error("Should reject invalid format"); - } catch (error) { - if (error.message.includes("Invalid date format")) { - console.log(" ✓ Rejects invalid date format"); - } else { - throw error; - } - } - - // Past date (beyond grace period) - try { - validateStartDate("2020-01-01"); - throw new Error("Should reject date too far in the past"); - } catch (error) { - if (error.message.includes("past")) { - console.log(" ✓ Rejects date too far in the past"); - } else { - throw error; - } - } - - console.log("✓ validateStartDate tests passed"); -} - -function testValidateDuration() { - // Valid durations - assert(validateDuration("P180D") === "P180D", "P180D should be valid"); - assert(validateDuration("P90D") === "P90D", "P90D should be valid"); - assert(validateDuration("P1D") === "P1D", "P1D should be valid"); - assert(validateDuration("p30d") === "P30D", "Should convert to uppercase"); - - // Invalid format - try { - validateDuration("180 days"); - throw new Error("Should reject invalid format"); - } catch (error) { - if (error.message.includes("Invalid duration format")) { - console.log(" ✓ Rejects invalid duration format"); - } else { - throw error; - } - } - - // Over 180 days - try { - validateDuration("P200D"); - throw new Error("Should reject over 180 days"); - } catch (error) { - if (error.message.includes("between 1 and 180")) { - console.log(" ✓ Rejects duration over 180 days"); - } else { - throw error; - } - } - - // Zero days - try { - validateDuration("P0D"); - throw new Error("Should reject 0 days"); - } catch (error) { - if (error.message.includes("between 1 and 180")) { - console.log(" ✓ Rejects zero duration"); - } else { - throw error; - } - } - - console.log("✓ validateDuration tests passed"); -} - -function testValidateRpNamespace() { - // Valid RPs - validateRpNamespace("Microsoft.Test"); - validateRpNamespace("Azure.Widget"); - validateRpNamespace("Contoso.Manager"); - - // Invalid RPs - try { - validateRpNamespace("microsoft.Test"); - throw new Error("Should reject lowercase start"); - } catch (error) { - if (error.message.includes("capital letter")) { - console.log(" ✓ Rejects lowercase start"); - } else { - throw error; - } - } - - console.log("✓ validateRpNamespace tests passed"); -} - -function testValidateOrgName() { - // Valid org names - validateOrgName("storage"); - validateOrgName("compute"); - validateOrgName("testservice123"); - - // Invalid org names - try { - validateOrgName("TestService"); - throw new Error("Should reject uppercase"); - } catch (error) { - if (error.message.includes("lowercase alphanumeric")) { - console.log(" ✓ Rejects uppercase"); - } else { - throw error; - } - } - - try { - validateOrgName("test-service"); - throw new Error("Should reject hyphen"); - } catch (error) { - if (error.message.includes("lowercase alphanumeric")) { - console.log(" ✓ Rejects hyphen"); - } else { - throw error; - } - } - - console.log("✓ validateOrgName tests passed"); -} - -function testValidateReviewer() { - // Valid reviewers (must start with @) - assert( - validateReviewer("@githubUser") === "@githubUser", - "Valid @ reviewer should pass", - ); - assert( - validateReviewer("@johnDoe") === "@johnDoe", - "Valid @ reviewer should pass", - ); - assert( - validateReviewer(" @trimmed ") === "@trimmed", - "Should trim whitespace", - ); - - // Invalid: missing @ prefix - try { - validateReviewer("githubUser"); - throw new Error("Should reject reviewer without @ prefix"); - } catch (error) { - if (error.message.includes("@")) { - console.log(" ✓ Rejects reviewer without @ prefix"); - } else { - throw error; - } - } - - // Invalid: only @ - try { - validateReviewer("@"); - throw new Error("Should reject @ only"); - } catch (error) { - if (error.message.includes("@")) { - console.log(" ✓ Rejects @ only"); - } else { - throw error; - } - } - - // Invalid: empty string - try { - validateReviewer(""); - throw new Error("Should reject empty reviewer"); - } catch (error) { - if (error.message.includes("required")) { - console.log(" ✓ Rejects empty reviewer"); - } else { - throw error; - } - } - - console.log("✓ validateReviewer tests passed"); -} - -function testGetTodayDate() { - const today = getTodayDate(); - assert( - /^\d{4}-\d{2}-\d{2}$/.test(today), - "Today should be in YYYY-MM-DD format", - ); - console.log(`✓ getTodayDate tests passed (today: ${today})`); -} - -function runTests() { - console.log("Running tests for generate-lease-files.js...\n"); - - try { - testParseInputLine(); - testGenerateLeaseYaml(); - testGetLeasePath(); - testValidateStartDate(); - testValidateDuration(); - testValidateRpNamespace(); - testValidateOrgName(); - testValidateReviewer(); - testGetTodayDate(); - - console.log("\n✅ All tests passed!"); - return 0; - } catch (error) { - console.error(`\n❌ ${error.message}`); - console.error(error.stack); - return 1; - } -} - -if (require.main === module) { - process.exit(runTests()); -} - -module.exports = { runTests }; diff --git a/.github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js b/.github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js new file mode 100644 index 000000000000..b1c3d10d825f --- /dev/null +++ b/.github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js @@ -0,0 +1,88 @@ +import { createRequire } from "module"; +import fs from "fs"; +import path from "path"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { findRepoRoot, findResourceProviders, formatOutput } = require( + "../../../arm-leases/scripts/fetch-resource-providers.cjs", +); + +describe("fetch-resource-providers", () => { + describe("findRepoRoot", () => { + it("finds the repository root", () => { + const repoRoot = findRepoRoot(); + expect(repoRoot.endsWith("azure-rest-api-specs")).toBe(true); + expect(fs.existsSync(path.join(repoRoot, "specification"))).toBe(true); + }); + }); + + describe("findResourceProviders (without service names)", () => { + it("finds resource providers without service names", () => { + const repoRoot = findRepoRoot(); + const rps = findResourceProviders(repoRoot, false); + + expect(rps.length).toBeGreaterThan(0); + expect(rps.every((rp) => rp.rpNamespace && rp.orgName && rp.path)).toBe(true); + expect(rps.every((rp) => !rp.serviceNames)).toBe(true); + expect(rps.some((rp) => rp.rpNamespace === "Microsoft.Storage")).toBe(true); + expect(rps.some((rp) => rp.rpNamespace === "Microsoft.Compute")).toBe(false); + }); + }); + + describe("findResourceProviders (with service names)", () => { + it("finds resource providers with service names", () => { + const repoRoot = findRepoRoot(); + const rps = findResourceProviders(repoRoot, true); + + expect(rps.length).toBeGreaterThan(0); + expect(rps.every((rp) => rp.rpNamespace && rp.orgName && rp.path && rp.serviceNames)).toBe( + true, + ); + expect(rps.every((rp) => Array.isArray(rp.serviceNames))).toBe(true); + + const compute = rps.find((rp) => rp.rpNamespace === "Microsoft.Compute"); + expect(compute).toBeDefined(); + expect(compute.orgName).toBe("compute"); + expect(compute.serviceNames.includes("Compute")).toBe(true); + expect(rps.some((rp) => rp.rpNamespace === "Microsoft.Storage")).toBe(false); + }); + }); + + describe("formatOutput", () => { + const rpsWithout = [{ rpNamespace: "Microsoft.Test", orgName: "test", path: "test/path" }]; + const rpsWith = [ + { + rpNamespace: "Microsoft.Test2", + orgName: "test2", + path: "test2/path", + serviceNames: ["Group1", "Group2"], + }, + ]; + + it("formats list output without service names", () => { + const output = formatOutput(rpsWithout, "list", false); + expect(output).toContain("test, Microsoft.Test"); + }); + + it("formats list output with service names", () => { + const output = formatOutput(rpsWith, "list", true); + expect(output).toContain("test2, Microsoft.Test2, [Group1, Group2]"); + }); + + it("formats JSON output", () => { + const output = formatOutput(rpsWithout, "json", false); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].rpNamespace).toBe("Microsoft.Test"); + }); + + it("formats table output", () => { + const output = formatOutput(rpsWithout, "table", false); + expect(output).toContain("orgName"); + expect(output).toContain("rpNamespace"); + expect(output).toContain("test"); + expect(output).toContain("Microsoft.Test"); + }); + }); +}); diff --git a/.github/workflows/test/arm-lease-scripts/generate-lease-files.test.js b/.github/workflows/test/arm-lease-scripts/generate-lease-files.test.js new file mode 100644 index 000000000000..150d089e0d8b --- /dev/null +++ b/.github/workflows/test/arm-lease-scripts/generate-lease-files.test.js @@ -0,0 +1,179 @@ +import { createRequire } from "module"; +import path from "path"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { + parseInputLine, + generateLeaseYaml, + getLeasePath, + validateStartDate, + validateDuration, + validateRpNamespace, + validateOrgName, + validateReviewer, + getTodayDate, +} = require("../../../arm-leases/scripts/generate-lease-files.cjs"); + +describe("generate-lease-files", () => { + describe("parseInputLine", () => { + it("parses line without service names", () => { + const result = parseInputLine("storage, Microsoft.Storage"); + expect(result.orgName).toBe("storage"); + expect(result.rpNamespace).toBe("Microsoft.Storage"); + expect(result.serviceNames).toHaveLength(0); + }); + + it("parses line with service names", () => { + const result = parseInputLine("compute, Microsoft.Compute, [ComputeRP, DiskRP]"); + expect(result.orgName).toBe("compute"); + expect(result.rpNamespace).toBe("Microsoft.Compute"); + expect(result.serviceNames).toHaveLength(2); + expect(result.serviceNames[0]).toBe("ComputeRP"); + expect(result.serviceNames[1]).toBe("DiskRP"); + }); + + it("returns null for empty line", () => { + expect(parseInputLine("")).toBeNull(); + }); + + it("returns null for comment", () => { + expect(parseInputLine("# This is a comment")).toBeNull(); + }); + }); + + describe("generateLeaseYaml", () => { + it("generates valid YAML content", () => { + const yaml = generateLeaseYaml("Microsoft.Test", "2026-06-01", "P180D", "@johnDoe"); + + expect(yaml).toContain("resource-provider: Microsoft.Test"); + expect(yaml).toContain('startdate: "2026-06-01"'); + expect(yaml).toContain("duration: P180D"); + expect(yaml).toContain('reviewer: "@johnDoe"'); + expect(yaml.endsWith("\n\n")).toBe(true); + }); + + it("does not use duration-days field", () => { + const yaml = generateLeaseYaml("Microsoft.Test", "2026-06-01", "P180D", "@johnDoe"); + expect(yaml).not.toContain("duration-days:"); + }); + }); + + describe("getLeasePath", () => { + it("returns correct path without service name", () => { + const result = getLeasePath("/repo", "storage", "Microsoft.Storage"); + const expected = path.join( + "/repo", + ".github", + "arm-leases", + "storage", + "Microsoft.Storage", + "lease.yaml", + ); + expect(result).toBe(expected); + }); + + it("returns correct path with service name", () => { + const result = getLeasePath("/repo", "compute", "Microsoft.Compute", "DiskRP"); + const expected = path.join( + "/repo", + ".github", + "arm-leases", + "compute", + "Microsoft.Compute", + "DiskRP", + "lease.yaml", + ); + expect(result).toBe(expected); + }); + }); + + describe("validateStartDate", () => { + it("accepts today's date", () => { + const today = getTodayDate(); + expect(() => validateStartDate(today)).not.toThrow(); + }); + + it("accepts future date", () => { + expect(() => validateStartDate("2027-12-31")).not.toThrow(); + }); + + it("accepts date within 10-day grace period", () => { + const fiveDaysAgo = new Date(); + fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5); + const fiveDaysAgoStr = fiveDaysAgo.toISOString().split("T")[0]; + expect(() => validateStartDate(fiveDaysAgoStr)).not.toThrow(); + }); + + it("rejects invalid date format", () => { + expect(() => validateStartDate("12/31/2026")).toThrow(/Invalid date format/); + }); + + it("rejects date too far in the past", () => { + expect(() => validateStartDate("2020-01-01")).toThrow(/past/); + }); + }); + + describe("validateDuration", () => { + it("accepts valid durations", () => { + expect(validateDuration("P180D")).toBe("P180D"); + expect(validateDuration("P90D")).toBe("P90D"); + expect(validateDuration("P1D")).toBe("P1D"); + }); + + it("converts lowercase to uppercase", () => { + expect(validateDuration("p30d")).toBe("P30D"); + }); + + it("rejects invalid format", () => { + expect(() => validateDuration("180 days")).toThrow(/Invalid duration format/); + }); + + it("rejects duration over 180 days", () => { + expect(() => validateDuration("P200D")).toThrow(/between 1 and 180/); + }); + + it("rejects zero duration", () => { + expect(() => validateDuration("P0D")).toThrow(/between 1 and 180/); + }); + }); + + describe("validateRpNamespace", () => { + it("accepts valid RP namespaces", () => { + expect(() => validateRpNamespace("Microsoft.Test")).not.toThrow(); + expect(() => validateRpNamespace("Azure.Widget")).not.toThrow(); + expect(() => validateRpNamespace("Contoso.Manager")).not.toThrow(); + }); + + it("rejects lowercase start", () => { + expect(() => validateRpNamespace("microsoft.Test")).toThrow(/capital letter/); + }); + }); + + describe("validateOrgName", () => { + it("accepts valid org names", () => { + expect(() => validateOrgName("storage")).not.toThrow(); + expect(() => validateOrgName("compute")).not.toThrow(); + expect(() => validateOrgName("test123")).not.toThrow(); + }); + + it("rejects empty org name", () => { + expect(() => validateOrgName("")).toThrow(); + }); + }); + + describe("validateReviewer", () => { + it("accepts valid reviewer format", () => { + expect(() => validateReviewer("@johnDoe")).not.toThrow(); + expect(() => validateReviewer("@jane-smith")).not.toThrow(); + }); + + it("rejects reviewer without @", () => { + expect(() => validateReviewer("johnDoe")).toThrow(/@/); + }); + + it("rejects empty reviewer", () => { + expect(() => validateReviewer("")).toThrow(); + }); + }); +}); diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js index 8d635f9d8fc0..f938398e10d6 100644 --- a/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js @@ -32,10 +32,8 @@ describe("validate-arm-leases", () => { }); it("allows script files under scripts folder", () => { - expect(isFileAllowed(".github/arm-leases/scripts/fetch-resource-providers.js")).toBe(true); - expect(isFileAllowed(".github/arm-leases/scripts/generate-lease-files.js")).toBe(true); - expect(isFileAllowed(".github/arm-leases/scripts/tests/fetch-resource-providers.test.js")).toBe(true); - expect(isFileAllowed(".github/arm-leases/scripts/tests/generate-lease-files.test.js")).toBe(true); + expect(isFileAllowed(".github/arm-leases/scripts/fetch-resource-providers.cjs")).toBe(true); + expect(isFileAllowed(".github/arm-leases/scripts/generate-lease-files.cjs")).toBe(true); }); it("rejects invalid files", () => { From 05e474592cb2c69d6fb25d840cc45af5e2761fac Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Thu, 30 Apr 2026 19:49:15 -0500 Subject: [PATCH 12/18] Fix findRepoRoot to use __dirname in tests --- .../fetch-resource-providers.test.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js b/.github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js index b1c3d10d825f..befdbf8a71cf 100644 --- a/.github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js +++ b/.github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js @@ -1,6 +1,7 @@ import { createRequire } from "module"; import fs from "fs"; import path from "path"; +import { fileURLToPath } from "url"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); @@ -8,10 +9,14 @@ const { findRepoRoot, findResourceProviders, formatOutput } = require( "../../../arm-leases/scripts/fetch-resource-providers.cjs", ); +// Get the directory of the current test file to find repo root reliably +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + describe("fetch-resource-providers", () => { describe("findRepoRoot", () => { it("finds the repository root", () => { - const repoRoot = findRepoRoot(); + const repoRoot = findRepoRoot(__dirname); expect(repoRoot.endsWith("azure-rest-api-specs")).toBe(true); expect(fs.existsSync(path.join(repoRoot, "specification"))).toBe(true); }); @@ -19,7 +24,7 @@ describe("fetch-resource-providers", () => { describe("findResourceProviders (without service names)", () => { it("finds resource providers without service names", () => { - const repoRoot = findRepoRoot(); + const repoRoot = findRepoRoot(__dirname); const rps = findResourceProviders(repoRoot, false); expect(rps.length).toBeGreaterThan(0); @@ -32,7 +37,7 @@ describe("fetch-resource-providers", () => { describe("findResourceProviders (with service names)", () => { it("finds resource providers with service names", () => { - const repoRoot = findRepoRoot(); + const repoRoot = findRepoRoot(__dirname); const rps = findResourceProviders(repoRoot, true); expect(rps.length).toBeGreaterThan(0); From 4d4841a95e78be46a199af179fa9ba2d1fcf56e0 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Fri, 1 May 2026 11:03:26 -0500 Subject: [PATCH 13/18] Moved to cmd folder --- .github/arm-leases/README.md | 24 +++---- .../arm-lease-fetch-resource-providers.js} | 42 +++++------ .../cmd/arm-lease-generate-lease-files.js} | 70 ++++++++----------- .../arm-lease-validation.js | 11 +-- ...rm-lease-fetch-resource-providers.test.js} | 10 +-- .../arm-lease-generate-lease-files.test.js} | 6 +- .../arm-lease-validation.test.js | 8 +-- 7 files changed, 72 insertions(+), 99 deletions(-) rename .github/{arm-leases/scripts/fetch-resource-providers.cjs => workflows/cmd/arm-lease-fetch-resource-providers.js} (85%) rename .github/{arm-leases/scripts/generate-lease-files.cjs => workflows/cmd/arm-lease-generate-lease-files.js} (87%) rename .github/workflows/test/{arm-lease-scripts/fetch-resource-providers.test.js => arm-lease-validation/arm-lease-fetch-resource-providers.test.js} (93%) rename .github/workflows/test/{arm-lease-scripts/generate-lease-files.test.js => arm-lease-validation/arm-lease-generate-lease-files.test.js} (97%) diff --git a/.github/arm-leases/README.md b/.github/arm-leases/README.md index 26fe9d713021..e3cb9b6a86ec 100644 --- a/.github/arm-leases/README.md +++ b/.github/arm-leases/README.md @@ -98,7 +98,7 @@ If your PR check **"ARM Lease Validation"** is failing, review the error message ## Automation Scripts -Scripts are located in `.github/arm-leases/scripts/`. Run from the repository root. +Scripts are located in `.github/workflows/cmd/`. Run from the repository root. ### Single Resource Provider @@ -106,20 +106,20 @@ Scripts are located in `.github/arm-leases/scripts/`. Run from the repository ro ```bash # Without service groups -node .github/arm-leases/scripts/generate-lease-files.cjs --orgName --rpNamespace --reviewer "@youralias" --startdate --duration +node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration # With service groups -node .github/arm-leases/scripts/generate-lease-files.cjs --orgName --rpNamespace --reviewer "@youralias" --startdate --duration --serviceName "," +node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName --rpNamespace --reviewer "@youralias" --startdate --duration --serviceName "," ``` **Examples:** ```bash # Without service groups -node .github/arm-leases/scripts/generate-lease-files.cjs --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" --startdate 2026-05-01 --duration P90D +node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" --startdate 2026-05-01 --duration P90D # With service groups -node .github/arm-leases/scripts/generate-lease-files.cjs --orgName compute --rpNamespace Microsoft.Compute --reviewer "@janesmith" --startdate 2026-05-01 --duration P180D --serviceName "DiskRP,ComputeRP,GalleryRP" +node .github/workflows/cmd/arm-lease-generate-lease-files.js --orgName compute --rpNamespace Microsoft.Compute --reviewer "@janesmith" --startdate 2026-05-01 --duration P180D --serviceName "DiskRP,ComputeRP,GalleryRP" ``` ### Bulk Generation @@ -128,23 +128,23 @@ node .github/arm-leases/scripts/generate-lease-files.cjs --orgName compute --rpN ```bash # Generate resource provider list (without service groups) -node .github/arm-leases/scripts/fetch-resource-providers.cjs --output +node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --output # Generate resource provider list (with service groups) -node .github/arm-leases/scripts/fetch-resource-providers.cjs --with-service-groups --output +node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --with-service-groups --output # Generate lease files from list -node .github/arm-leases/scripts/generate-lease-files.cjs --input --reviewer "@youralias" --startdate --duration +node .github/workflows/cmd/arm-lease-generate-lease-files.js --input --reviewer "@youralias" --startdate --duration ``` **Examples:** ```bash # RPs without service groups (e.g., Microsoft.Storage) -node .github/arm-leases/scripts/fetch-resource-providers.cjs --output rps-simple.txt -node .github/arm-leases/scripts/generate-lease-files.cjs --input rps-simple.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D +node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --output rps-simple.txt +node .github/workflows/cmd/arm-lease-generate-lease-files.js --input rps-simple.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D # RPs with service groups (e.g., Microsoft.Compute with DiskRP, ComputeRP) -node .github/arm-leases/scripts/fetch-resource-providers.cjs --with-service-groups --output rps-groups.txt -node .github/arm-leases/scripts/generate-lease-files.cjs --input rps-groups.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D +node .github/workflows/cmd/arm-lease-fetch-resource-providers.js --with-service-groups --output rps-groups.txt +node .github/workflows/cmd/arm-lease-generate-lease-files.js --input rps-groups.txt --reviewer "@johndoe" --startdate 2026-04-16 --duration P180D ``` diff --git a/.github/arm-leases/scripts/fetch-resource-providers.cjs b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js similarity index 85% rename from .github/arm-leases/scripts/fetch-resource-providers.cjs rename to .github/workflows/cmd/arm-lease-fetch-resource-providers.js index 8a0c892ce41f..14105d5ed348 100644 --- a/.github/arm-leases/scripts/fetch-resource-providers.cjs +++ b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js @@ -1,11 +1,11 @@ -#!/usr/bin/env node // Fetch Azure resource providers with or without service names (service groups) -// Usage: node fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] [--output FILE] +// Usage: node arm-lease-fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] [--output FILE] -const fs = require("fs"); -const path = require("path"); +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; -function isServiceNameDirectory(dirPath) { +export function isServiceNameDirectory(dirPath) { const excludeNames = new Set([ "stable", "preview", @@ -22,16 +22,16 @@ function isServiceNameDirectory(dirPath) { } } -function hasVersionDirectories(rpPath) { +export function hasVersionDirectories(rpPath) { return ( fs.existsSync(path.join(rpPath, "stable")) || fs.existsSync(path.join(rpPath, "preview")) ); } -function findRepoRoot(startPath = process.cwd()) { +export function findRepoRoot(startPath = process.cwd()) { let current = path.resolve(startPath); - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 10; i++) { if (fs.existsSync(path.join(current, "specification"))) return current; const parent = path.dirname(current); if (parent === current) break; @@ -42,7 +42,7 @@ function findRepoRoot(startPath = process.cwd()) { ); } -function findResourceProviders(repoRoot, withServiceNames = false) { +export function findResourceProviders(repoRoot, withServiceNames = false) { const results = []; const specDir = path.join(repoRoot, "specification"); if (!fs.existsSync(specDir)) @@ -91,7 +91,7 @@ function findResourceProviders(repoRoot, withServiceNames = false) { return results.sort((a, b) => a.rpNamespace.localeCompare(b.rpNamespace)); } -function formatOutput(rps, format, withSN) { +export function formatOutput(rps, format, withSN) { if (format === "json") return JSON.stringify(rps, null, 2); if (rps.length === 0) return `No resource providers ${withSN ? "with" : "without"} serviceNames found.`; @@ -130,7 +130,7 @@ function printHelp() { Fetch Azure resource providers with or without service names (service groups) Usage: - node fetch-resource-providers.js [options] + node arm-lease-fetch-resource-providers.js [options] Options: --with-service-groups Include only RPs with serviceNames (service groups) @@ -142,16 +142,16 @@ Options: Examples: # RPs without service names - node fetch-resource-providers.js + node arm-lease-fetch-resource-providers.js # RPs with service names - node fetch-resource-providers.js --with-service-groups + node arm-lease-fetch-resource-providers.js --with-service-groups # Output to file - node fetch-resource-providers.js --output rps.txt + node arm-lease-fetch-resource-providers.js --output rps.txt # JSON format - node fetch-resource-providers.js --format json + node arm-lease-fetch-resource-providers.js --format json `); } @@ -207,14 +207,8 @@ function main() { } } -if (require.main === module) { +// Check if this module is being run directly +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) { process.exit(main()); } - -module.exports = { - findRepoRoot, - findResourceProviders, - formatOutput, - isServiceNameDirectory, - hasVersionDirectories, -}; diff --git a/.github/arm-leases/scripts/generate-lease-files.cjs b/.github/workflows/cmd/arm-lease-generate-lease-files.js similarity index 87% rename from .github/arm-leases/scripts/generate-lease-files.cjs rename to .github/workflows/cmd/arm-lease-generate-lease-files.js index 83b6e326b0f8..f4b2d587b2e8 100644 --- a/.github/arm-leases/scripts/generate-lease-files.cjs +++ b/.github/workflows/cmd/arm-lease-generate-lease-files.js @@ -1,9 +1,9 @@ -#!/usr/bin/env node // Generate lease.yaml files from resource provider data -const fs = require("fs"); -const path = require("path"); -const readline = require("readline"); +import fs from "fs"; +import path from "path"; +import readline from "readline"; +import { fileURLToPath } from "url"; const DEFAULT_DURATION = "P180D"; const LEASE_BASE_PATH = ".github/arm-leases"; @@ -83,13 +83,13 @@ Generate lease.yaml files for Azure Resource Providers Usage: Single RP: - node generate-lease-files.js --orgName --rpNamespace --reviewer [options] + node arm-lease-generate-lease-files.js --orgName --rpNamespace --reviewer [options] From input file: - node generate-lease-files.js --input --reviewer [options] + node arm-lease-generate-lease-files.js --input --reviewer [options] Interactive mode: - node generate-lease-files.js --interactive + node arm-lease-generate-lease-files.js --interactive Options: --orgName Organization/service name (lowercase alphanumeric) @@ -114,25 +114,25 @@ Input File Format: Examples: # Single RP without service groups - node generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johnDoe" + node arm-lease-generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johnDoe" # Single RP with service groups - node generate-lease-files.js --orgName compute --rpNamespace Microsoft.Compute \\ + node arm-lease-generate-lease-files.js --orgName compute --rpNamespace Microsoft.Compute \\ --serviceName "ComputeRP,DiskRP" --reviewer "@janeSmith" - # From fetch-resource-providers.js output - node fetch-resource-providers.js > rps.txt - node generate-lease-files.js --input rps.txt --reviewer "@johnDoe" + # From arm-lease-fetch-resource-providers.js output + node arm-lease-fetch-resource-providers.js > rps.txt + node arm-lease-generate-lease-files.js --input rps.txt --reviewer "@johnDoe" - # From fetch-resource-providers.js with service groups - node fetch-resource-providers.js --with-service-groups > rps-with-groups.txt - node generate-lease-files.js --input rps-with-groups.txt --reviewer "@janeSmith" + # From arm-lease-fetch-resource-providers.js with service groups + node arm-lease-fetch-resource-providers.js --with-service-groups > rps-with-groups.txt + node arm-lease-generate-lease-files.js --input rps-with-groups.txt --reviewer "@janeSmith" # Interactive mode - node generate-lease-files.js --interactive + node arm-lease-generate-lease-files.js --interactive # Dry run to preview - node generate-lease-files.js --input rps.txt --reviewer "@testUser" --dry-run + node arm-lease-generate-lease-files.js --input rps.txt --reviewer "@testUser" --dry-run Output: Creates lease.yaml files at: @@ -149,7 +149,7 @@ Output: function findRepoRoot(startPath = process.cwd()) { let current = path.resolve(startPath); - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 10; i++) { if (fs.existsSync(path.join(current, "specification"))) { return current; } @@ -162,7 +162,7 @@ function findRepoRoot(startPath = process.cwd()) { ); } -function validateStartDate(date) { +export function validateStartDate(date) { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD`); } @@ -183,7 +183,7 @@ function validateStartDate(date) { return date; } -function validateDuration(duration) { +export function validateDuration(duration) { const match = duration.match(/^P(\d+)D$/i); if (!match) { throw new Error( @@ -199,7 +199,7 @@ function validateDuration(duration) { return duration.toUpperCase(); } -function validateRpNamespace(rpNamespace) { +export function validateRpNamespace(rpNamespace) { const parts = rpNamespace.split("."); for (const part of parts) { if (!/^[A-Z]/.test(part)) { @@ -211,14 +211,14 @@ function validateRpNamespace(rpNamespace) { return rpNamespace; } -function validateOrgName(orgName) { +export function validateOrgName(orgName) { if (!/^[a-z0-9]+$/.test(orgName)) { throw new Error(`orgName must be lowercase alphanumeric: ${orgName}`); } return orgName; } -function validateReviewer(reviewer) { +export function validateReviewer(reviewer) { if (!reviewer || reviewer.trim().length === 0) { throw new Error("Reviewer is required and cannot be empty"); } @@ -231,7 +231,7 @@ function validateReviewer(reviewer) { return trimmed; } -function parseInputLine(line) { +export function parseInputLine(line) { line = line.trim(); if (!line || line.startsWith("#")) { return null; @@ -256,7 +256,7 @@ function parseInputLine(line) { return { orgName, rpNamespace, serviceNames }; } -function generateLeaseYaml(rpNamespace, startdate, duration, reviewer) { +export function generateLeaseYaml(rpNamespace, startdate, duration, reviewer) { return `lease: resource-provider: ${rpNamespace} startdate: "${startdate}" @@ -266,7 +266,7 @@ function generateLeaseYaml(rpNamespace, startdate, duration, reviewer) { `; } -function getLeasePath(repoRoot, orgName, rpNamespace, serviceName = null) { +export function getLeasePath(repoRoot, orgName, rpNamespace, serviceName = null) { const basePath = path.join(repoRoot, LEASE_BASE_PATH, orgName, rpNamespace); if (serviceName) { return path.join(basePath, serviceName, "lease.yaml"); @@ -394,7 +394,7 @@ async function promptInteractive() { }; } -function getTodayDate() { +export function getTodayDate() { const today = new Date(); return today.toISOString().split("T")[0]; } @@ -513,18 +513,8 @@ async function main() { } } -if (require.main === module) { +// Check if this module is being run directly +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) { main().then((code) => process.exit(code)); } - -module.exports = { - parseInputLine, - generateLeaseYaml, - getLeasePath, - validateStartDate, - validateDuration, - validateRpNamespace, - validateOrgName, - validateReviewer, - getTodayDate, -}; diff --git a/.github/workflows/src/arm-lease-validation/arm-lease-validation.js b/.github/workflows/src/arm-lease-validation/arm-lease-validation.js index ec98a3ca3ae1..08dbef01a837 100644 --- a/.github/workflows/src/arm-lease-validation/arm-lease-validation.js +++ b/.github/workflows/src/arm-lease-validation/arm-lease-validation.js @@ -20,7 +20,6 @@ export const ALLOWED_FILE_PATTERNS = [ LEASE_FILE_PATTERN, LEASE_FILE_WITH_GROUP_PATTERN, /^\.github\/arm-leases\/README\.md$/, - /^\.github\/arm-leases\/scripts\/.+$/, ]; /** @@ -262,10 +261,7 @@ export default async function validateArmLeases(core) { // Check for non-lease.yaml and non-README files core.startGroup("Checking for non-lease files"); const nonLeaseFiles = allChangedFiles.filter( - (file) => - !file.endsWith("/lease.yaml") && - !file.endsWith("/README.md") && - !file.startsWith(".github/arm-leases/scripts/"), + (file) => !file.endsWith("/lease.yaml") && !file.endsWith("/README.md"), ); if (nonLeaseFiles.length > 0) { @@ -282,10 +278,7 @@ export default async function validateArmLeases(core) { // Get ARM lease files (only lease.yaml files) const armLeaseFiles = allChangedFiles.filter( - (file) => - file.startsWith(".github/arm-leases/") && - !file.endsWith(".md") && - !file.startsWith(".github/arm-leases/scripts/"), + (file) => file.startsWith(".github/arm-leases/") && !file.endsWith(".md"), ); if (armLeaseFiles.length === 0) { diff --git a/.github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js similarity index 93% rename from .github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js rename to .github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js index befdbf8a71cf..1e5b4fadd80f 100644 --- a/.github/workflows/test/arm-lease-scripts/fetch-resource-providers.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js @@ -1,13 +1,13 @@ -import { createRequire } from "module"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { describe, expect, it } from "vitest"; -const require = createRequire(import.meta.url); -const { findRepoRoot, findResourceProviders, formatOutput } = require( - "../../../arm-leases/scripts/fetch-resource-providers.cjs", -); +import { + findRepoRoot, + findResourceProviders, + formatOutput, +} from "../../cmd/arm-lease-fetch-resource-providers.js"; // Get the directory of the current test file to find repo root reliably const __filename = fileURLToPath(import.meta.url); diff --git a/.github/workflows/test/arm-lease-scripts/generate-lease-files.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js similarity index 97% rename from .github/workflows/test/arm-lease-scripts/generate-lease-files.test.js rename to .github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js index 150d089e0d8b..4af7f527156f 100644 --- a/.github/workflows/test/arm-lease-scripts/generate-lease-files.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js @@ -1,9 +1,7 @@ -import { createRequire } from "module"; import path from "path"; import { describe, expect, it } from "vitest"; -const require = createRequire(import.meta.url); -const { +import { parseInputLine, generateLeaseYaml, getLeasePath, @@ -13,7 +11,7 @@ const { validateOrgName, validateReviewer, getTodayDate, -} = require("../../../arm-leases/scripts/generate-lease-files.cjs"); +} from "../../cmd/arm-lease-generate-lease-files.js"; describe("generate-lease-files", () => { describe("parseInputLine", () => { diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js index f938398e10d6..0d968d04ee1f 100644 --- a/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-validation.test.js @@ -31,15 +31,13 @@ describe("validate-arm-leases", () => { expect(isFileAllowed(".github/arm-leases/README.md")).toBe(true); }); - it("allows script files under scripts folder", () => { - expect(isFileAllowed(".github/arm-leases/scripts/fetch-resource-providers.cjs")).toBe(true); - expect(isFileAllowed(".github/arm-leases/scripts/generate-lease-files.cjs")).toBe(true); - }); - it("rejects invalid files", () => { expect(isFileAllowed(".github/arm-leases/anything/here")).toBe(false); expect(isFileAllowed(".github/arm-leases/testservice/Microsoft.Test/other.yaml")).toBe(false); expect(isFileAllowed(".github/arm-leases/badtest/No.Yaml/no.md")).toBe(false); + // Scripts should now be in .github/workflows/cmd/, not allowed in arm-leases folder + expect(isFileAllowed(".github/arm-leases/scripts/fetch-resource-providers.js")).toBe(false); + expect(isFileAllowed(".github/arm-leases/scripts/generate-lease-files.js")).toBe(false); }); }); From 6b5cdb3b24ffd03c3a2c29df05d75dc29b8a38fc Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Fri, 1 May 2026 11:48:54 -0500 Subject: [PATCH 14/18] Clean code --- .../cmd/arm-lease-fetch-resource-providers.js | 236 +++---- .../cmd/arm-lease-generate-lease-files.js | 588 +++++++----------- 2 files changed, 362 insertions(+), 462 deletions(-) diff --git a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js index 14105d5ed348..382af84449fc 100644 --- a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js +++ b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js @@ -1,88 +1,97 @@ -// Fetch Azure resource providers with or without service names (service groups) -// Usage: node arm-lease-fetch-resource-providers.js [--with-service-groups] [--format list|json|table] [--count] [--output FILE] - -import fs from "fs"; -import path from "path"; +import { existsSync, readdirSync, statSync, writeFileSync } from "fs"; +import { basename, dirname, join, relative, resolve } from "path"; import { fileURLToPath } from "url"; +import { parseArgs } from "util"; +/** + * Check if a directory is a service name directory (not stable, preview, common-types, or examples) + * @param {string} dirPath - Path to the directory + * @returns {boolean} True if it's a valid service name directory + */ export function isServiceNameDirectory(dirPath) { - const excludeNames = new Set([ - "stable", - "preview", - "common-types", - "examples", - ]); + const excludeNames = new Set(["stable", "preview", "common-types", "examples"]); try { - return ( - !excludeNames.has(path.basename(dirPath)) && - fs.statSync(dirPath).isDirectory() - ); + return !excludeNames.has(basename(dirPath)) && statSync(dirPath).isDirectory(); } catch { return false; } } +/** + * Check if a resource provider path has version directories (stable or preview) + * @param {string} rpPath - Path to the resource provider + * @returns {boolean} True if it has version directories + */ export function hasVersionDirectories(rpPath) { - return ( - fs.existsSync(path.join(rpPath, "stable")) || - fs.existsSync(path.join(rpPath, "preview")) - ); + return existsSync(join(rpPath, "stable")) || existsSync(join(rpPath, "preview")); } +/** + * Find the repository root by looking for the specification directory + * @param {string} [startPath] - Starting path to search from + * @returns {string} The repository root path + * @throws {Error} If repository root cannot be found + */ export function findRepoRoot(startPath = process.cwd()) { - let current = path.resolve(startPath); - for (let i = 0; i < 10; i++) { - if (fs.existsSync(path.join(current, "specification"))) return current; - const parent = path.dirname(current); + let current = resolve(startPath); + for (let i = 0; i < 20; i++) { + if (existsSync(join(current, "specification"))) return current; + const parent = dirname(current); if (parent === current) break; current = parent; } - throw new Error( - "Could not find repository root. Run from within azure-rest-api-specs.", - ); + throw new Error("Could not find repository root. Run from within azure-rest-api-specs."); } +/** + * @typedef {Object} ResourceProvider + * @property {string} rpNamespace - Resource provider namespace (e.g., Microsoft.Storage) + * @property {string} path - Relative path to the resource provider + * @property {string} orgName - Organization name from specification folder + * @property {string[]} [serviceNames] - Service names if withServiceNames is true + */ + +/** + * Find all resource providers in the specification directory + * @param {string} repoRoot - Repository root path + * @param {boolean} [withServiceNames=false] - Whether to include only RPs with service names + * @returns {ResourceProvider[]} Array of resource provider objects + */ export function findResourceProviders(repoRoot, withServiceNames = false) { const results = []; - const specDir = path.join(repoRoot, "specification"); - if (!fs.existsSync(specDir)) + const specDir = join(repoRoot, "specification"); + if (!existsSync(specDir)) { throw new Error(`Specification directory not found: ${specDir}`); + } - for (const orgName of fs.readdirSync(specDir)) { - const orgDir = path.join(specDir, orgName); - if (!fs.statSync(orgDir).isDirectory()) continue; + for (const orgName of readdirSync(specDir)) { + const orgDir = join(specDir, orgName); + if (!statSync(orgDir).isDirectory()) continue; - const rmDir = path.join(orgDir, "resource-manager"); - if (!fs.existsSync(rmDir)) continue; + const rmDir = join(orgDir, "resource-manager"); + if (!existsSync(rmDir)) continue; - for (const rpNamespace of fs.readdirSync(rmDir)) { - const rpPath = path.join(rmDir, rpNamespace); - if ( - !fs.statSync(rpPath).isDirectory() || - !rpNamespace.startsWith("Microsoft.") - ) + for (const rpNamespace of readdirSync(rmDir)) { + const rpPath = join(rmDir, rpNamespace); + if (!statSync(rpPath).isDirectory() || !rpNamespace.startsWith("Microsoft.")) { continue; + } - const serviceNames = fs - .readdirSync(rpPath) - .filter((sn) => isServiceNameDirectory(path.join(rpPath, sn))) + const serviceNames = readdirSync(rpPath) + .filter((sn) => isServiceNameDirectory(join(rpPath, sn))) .sort(); if (withServiceNames && serviceNames.length > 0) { results.push({ rpNamespace: rpNamespace, - path: path.relative(repoRoot, rpPath), + path: relative(repoRoot, rpPath), orgName: orgName, serviceNames: serviceNames, }); - } else if ( - !withServiceNames && - serviceNames.length === 0 && - hasVersionDirectories(rpPath) - ) { + } else if (!withServiceNames && serviceNames.length === 0 && hasVersionDirectories(rpPath)) { results.push({ rpNamespace: rpNamespace, - path: path.relative(repoRoot, rpPath), + path: relative(repoRoot, rpPath), orgName: orgName, }); } @@ -91,12 +100,20 @@ export function findResourceProviders(repoRoot, withServiceNames = false) { return results.sort((a, b) => a.rpNamespace.localeCompare(b.rpNamespace)); } -export function formatOutput(rps, format, withSN) { - if (format === "json") return JSON.stringify(rps, null, 2); - if (rps.length === 0) +/** + * Format resource providers for output + * @param {ResourceProvider[]} rps - Array of resource providers + * @param {"list"|"json"|"table"} fmt - Output format + * @param {boolean} withSN - Whether resource providers have service names + * @returns {string} Formatted output string + */ +export function formatOutput(rps, fmt, withSN) { + if (fmt === "json") return JSON.stringify(rps, null, 2); + if (rps.length === 0) { return `No resource providers ${withSN ? "with" : "without"} serviceNames found.`; + } - if (format === "table") { + if (fmt === "table") { const maxOrg = Math.max(...rps.map((r) => r.orgName.length)); const maxRp = Math.max(...rps.map((r) => r.rpNamespace.length)); const header = withSN @@ -109,106 +126,95 @@ export function formatOutput(rps, format, withSN) { `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.serviceNames.join(", ")}`, ) : rps.map( - (r) => - `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.path}`, + (r) => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.path}`, ); return [header, sep, ...rows].join("\n"); } return withSN - ? rps - .map( - (r) => - `${r.orgName}, ${r.rpNamespace}, [${r.serviceNames.join(", ")}]`, - ) - .join("\n") + ? rps.map((r) => `${r.orgName}, ${r.rpNamespace}, [${r.serviceNames.join(", ")}]`).join("\n") : rps.map((r) => `${r.orgName}, ${r.rpNamespace}`).join("\n"); } -function printHelp() { - console.log(` -Fetch Azure resource providers with or without service names (service groups) +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); -Usage: - node arm-lease-fetch-resource-providers.js [options] +function usage() { + console.log(`Usage: +npx arm-lease-fetch-resource-providers [options] Options: --with-service-groups Include only RPs with serviceNames (service groups) - --format list|json|table Output format (default: list) + --format Output format: list, json, table (default: list) --count Output only the count - --output FILE Write output to file instead of stdout - --repo-root PATH Local repo root path (auto-detected if not provided) - --help, -h Show this help message + --output Write output to file instead of stdout + --repo-root Repository root path (auto-detected if not provided) + --help Show this help message Examples: # RPs without service names - node arm-lease-fetch-resource-providers.js + npx arm-lease-fetch-resource-providers # RPs with service names - node arm-lease-fetch-resource-providers.js --with-service-groups + npx arm-lease-fetch-resource-providers --with-service-groups # Output to file - node arm-lease-fetch-resource-providers.js --output rps.txt + npx arm-lease-fetch-resource-providers --output rps.txt # JSON format - node arm-lease-fetch-resource-providers.js --format json -`); + npx arm-lease-fetch-resource-providers --format json`); } -function main() { - const args = { - repoRoot: null, - format: "list", - count: false, - withSN: false, - output: null, - }; - - for (let i = 2; i < process.argv.length; i++) { - const arg = process.argv[i]; - if (arg === "--help" || arg === "-h") { - printHelp(); - return 0; - } - if (arg === "--repo-root") args.repoRoot = process.argv[++i]; - else if (arg === "--format") args.format = process.argv[++i]; - else if (arg === "--output" || arg === "-o") - args.output = process.argv[++i]; - else if (arg === "--count") args.count = true; - else if (arg === "--with-service-groups") args.withSN = true; +// Only run CLI if this file is executed directly (not imported) +if (process.argv[1] === __filename) { + const { + values: { + "with-service-groups": withServiceGroups, + format, + count, + output, + "repo-root": repoRootArg, + help, + }, + } = parseArgs({ + options: { + "with-service-groups": { type: "boolean", default: false }, + format: { type: "string", default: "list" }, + count: { type: "boolean", default: false }, + output: { type: "string", default: "" }, + "repo-root": { type: "string", default: "" }, + help: { type: "boolean", default: false }, + }, + allowPositionals: false, + }); + + if (help) { + usage(); + process.exit(0); } try { - const repoRoot = args.repoRoot - ? path.resolve(args.repoRoot) - : findRepoRoot(); - const rps = findResourceProviders(repoRoot, args.withSN); + const repoRoot = repoRootArg ? resolve(repoRootArg) : findRepoRoot(resolve(__dirname, "../../../")); + const rps = findResourceProviders(repoRoot, withServiceGroups); let outputText; - if (args.count) { + if (count) { outputText = String(rps.length); } else { - outputText = formatOutput(rps, args.format, args.withSN); - if (args.format !== "json") { - outputText += `\n\nTotal: ${rps.length} resource provider(s) ${args.withSN ? "with" : "without"} serviceNames`; + outputText = formatOutput(rps, format, withServiceGroups); + if (format !== "json") { + outputText += `\n\nTotal: ${rps.length} resource provider(s) ${withServiceGroups ? "with" : "without"} serviceNames`; } } - if (args.output) { - fs.writeFileSync(args.output, outputText + "\n"); - console.log(`Output written to ${args.output}`); + if (output) { + writeFileSync(output, outputText + "\n"); + console.log(`Output written to ${output}`); } else { console.log(outputText); } - return 0; } catch (error) { console.error(`Error: ${error.message}`); - return 1; + process.exit(1); } } - -// Check if this module is being run directly -const __filename = fileURLToPath(import.meta.url); -if (process.argv[1] === __filename) { - process.exit(main()); -} diff --git a/.github/workflows/cmd/arm-lease-generate-lease-files.js b/.github/workflows/cmd/arm-lease-generate-lease-files.js index f4b2d587b2e8..d4a3a76b3112 100644 --- a/.github/workflows/cmd/arm-lease-generate-lease-files.js +++ b/.github/workflows/cmd/arm-lease-generate-lease-files.js @@ -1,167 +1,26 @@ -// Generate lease.yaml files from resource provider data - -import fs from "fs"; -import path from "path"; -import readline from "readline"; +import { createInterface } from "readline"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; +import { parseArgs } from "util"; +import { findRepoRoot } from "./arm-lease-fetch-resource-providers.js"; const DEFAULT_DURATION = "P180D"; const LEASE_BASE_PATH = ".github/arm-leases"; -function parseArgs() { - const args = { - input: null, - orgName: null, - rpNamespace: null, - serviceNames: [], - reviewer: null, - startdate: null, - duration: DEFAULT_DURATION, - repoRoot: null, - dryRun: false, - interactive: false, - }; - - for (let i = 2; i < process.argv.length; i++) { - const arg = process.argv[i]; - switch (arg) { - case "--help": - case "-h": - printHelp(); - process.exit(0); - case "--input": - args.input = process.argv[++i]; - break; - case "--orgName": - case "--service": - args.orgName = process.argv[++i]; - break; - case "--rpNamespace": - case "--resource-provider": - case "--rp": - args.rpNamespace = process.argv[++i]; - break; - case "--serviceName": - case "--service-groups": - case "--sg": - args.serviceNames = process.argv[++i] - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - break; - case "--reviewer": - args.reviewer = process.argv[++i]; - break; - case "--startdate": - args.startdate = process.argv[++i]; - break; - case "--duration": - args.duration = process.argv[++i]; - break; - case "--repo-root": - args.repoRoot = process.argv[++i]; - break; - case "--dry-run": - args.dryRun = true; - break; - case "--interactive": - case "-i": - args.interactive = true; - break; - default: - console.error(`Unknown argument: ${arg}`); - process.exit(1); - } - } - - return args; -} - -function printHelp() { - console.log(` -Generate lease.yaml files for Azure Resource Providers - -Usage: - Single RP: - node arm-lease-generate-lease-files.js --orgName --rpNamespace --reviewer [options] - - From input file: - node arm-lease-generate-lease-files.js --input --reviewer [options] - - Interactive mode: - node arm-lease-generate-lease-files.js --interactive - -Options: - --orgName Organization/service name (lowercase alphanumeric) - --rpNamespace Resource provider namespace (e.g., Microsoft.Storage) - --serviceName Comma-separated service groups (e.g., DiskRP,ComputeRP) - --reviewer Reviewer GitHub alias starting with @ (e.g., @githubUser) - --startdate Lease start date (default: today) - --duration Lease duration (default: P180D, max: P180D) - --repo-root Repository root path (auto-detected if not provided) - --dry-run Show what would be created without writing files - --interactive, -i Interactive mode with prompts - --help, -h Show this help message - -Input File Format: - The input file should contain one entry per line in CSV format: - - Without service groups: orgName, rpNamespace - - With service groups: orgName, rpNamespace, [serviceName1, serviceName2, ...] - - Example: - storage, Microsoft.Storage - compute, Microsoft.Compute, [ComputeRP, DiskRP, GalleryRP] - -Examples: - # Single RP without service groups - node arm-lease-generate-lease-files.js --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johnDoe" - - # Single RP with service groups - node arm-lease-generate-lease-files.js --orgName compute --rpNamespace Microsoft.Compute \\ - --serviceName "ComputeRP,DiskRP" --reviewer "@janeSmith" - - # From arm-lease-fetch-resource-providers.js output - node arm-lease-fetch-resource-providers.js > rps.txt - node arm-lease-generate-lease-files.js --input rps.txt --reviewer "@johnDoe" - - # From arm-lease-fetch-resource-providers.js with service groups - node arm-lease-fetch-resource-providers.js --with-service-groups > rps-with-groups.txt - node arm-lease-generate-lease-files.js --input rps-with-groups.txt --reviewer "@janeSmith" - - # Interactive mode - node arm-lease-generate-lease-files.js --interactive - - # Dry run to preview - node arm-lease-generate-lease-files.js --input rps.txt --reviewer "@testUser" --dry-run - -Output: - Creates lease.yaml files at: - .github/arm-leases///[/]lease.yaml - - Each file contains: - lease: - resource-provider: - startdate: - duration: - reviewer: <@githubAlias> -`); -} - -function findRepoRoot(startPath = process.cwd()) { - let current = path.resolve(startPath); - for (let i = 0; i < 10; i++) { - if (fs.existsSync(path.join(current, "specification"))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - throw new Error( - "Could not find repository root. Run from within azure-rest-api-specs.", - ); +/** + * Get today's date in ISO format (YYYY-MM-DD) + * @returns {string} Today's date + */ +export function getTodayDate() { + return new Date().toISOString().split("T")[0]; } +/** + * Validate a start date + * @param {string} date - Date string in YYYY-MM-DD format + * @returns {string} The validated date + */ export function validateStartDate(date) { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD`); @@ -175,42 +34,48 @@ export function validateStartDate(date) { gracePeriodDate.setDate(gracePeriodDate.getDate() - 10); if (dateObj < gracePeriodDate) { - throw new Error( - `Startdate is too far in the past: ${date} (must be within 10 days of today)`, - ); + throw new Error(`Startdate is too far in the past: ${date} (must be within 10 days of today)`); } - return date; } +/** + * Validate a duration string + * @param {string} duration - Duration in ISO 8601 format (e.g., P180D) + * @returns {string} The validated duration (uppercase) + */ export function validateDuration(duration) { const match = duration.match(/^P(\d+)D$/i); if (!match) { - throw new Error( - `Invalid duration format: ${duration}. Expected P#D (e.g., P180D)`, - ); + throw new Error(`Invalid duration format: ${duration}. Expected P#D (e.g., P180D)`); } - const days = parseInt(match[1], 10); if (days <= 0 || days > 180) { throw new Error(`Duration must be between 1 and 180 days. Got: ${days}`); } - return duration.toUpperCase(); } +/** + * Validate a resource provider namespace + * @param {string} rpNamespace - Resource provider namespace (e.g., Microsoft.Storage) + * @returns {string} The validated namespace + */ export function validateRpNamespace(rpNamespace) { const parts = rpNamespace.split("."); for (const part of parts) { if (!/^[A-Z]/.test(part)) { - throw new Error( - `rpNamespace parts must start with capital letter: ${rpNamespace}`, - ); + throw new Error(`rpNamespace parts must start with capital letter: ${rpNamespace}`); } } return rpNamespace; } +/** + * Validate an organization name + * @param {string} orgName - Organization name + * @returns {string} The validated org name + */ export function validateOrgName(orgName) { if (!/^[a-z0-9]+$/.test(orgName)) { throw new Error(`orgName must be lowercase alphanumeric: ${orgName}`); @@ -218,24 +83,30 @@ export function validateOrgName(orgName) { return orgName; } +/** + * Validate a reviewer GitHub alias + * @param {string} reviewer - Reviewer GitHub alias + * @returns {string} The validated reviewer (trimmed) + */ export function validateReviewer(reviewer) { if (!reviewer || reviewer.trim().length === 0) { throw new Error("Reviewer is required and cannot be empty"); } const trimmed = reviewer.trim(); if (!trimmed.startsWith("@") || trimmed.length <= 1) { - throw new Error( - `Reviewer must be a GitHub alias starting with @ (e.g., @githubUser). Got: ${reviewer}`, - ); + throw new Error(`Reviewer must be a GitHub alias starting with @ (e.g., @githubUser). Got: ${reviewer}`); } return trimmed; } +/** + * Parse an input line in CSV format + * @param {string} line - Input line to parse + * @returns {{orgName: string, rpNamespace: string, serviceNames: string[]}|null} Parsed entry or null + */ export function parseInputLine(line) { line = line.trim(); - if (!line || line.startsWith("#")) { - return null; - } + if (!line || line.startsWith("#")) return null; const match = line.match(/^([^,]+),\s*([^,\[]+)(?:,\s*\[([^\]]+)\])?$/); if (!match) { @@ -247,15 +118,20 @@ export function parseInputLine(line) { const rpNamespace = match[2].trim(); const serviceNamesStr = match[3]; const serviceNames = serviceNamesStr - ? serviceNamesStr - .split(",") - .map((s) => s.trim()) - .filter(Boolean) + ? serviceNamesStr.split(",").map((s) => s.trim()).filter(Boolean) : []; return { orgName, rpNamespace, serviceNames }; } +/** + * Generate lease.yaml content + * @param {string} rpNamespace - Resource provider namespace + * @param {string} startdate - Start date in YYYY-MM-DD format + * @param {string} duration - Duration in ISO 8601 format + * @param {string} reviewer - Reviewer GitHub alias + * @returns {string} YAML content for lease file + */ export function generateLeaseYaml(rpNamespace, startdate, duration, reviewer) { return `lease: resource-provider: ${rpNamespace} @@ -266,108 +142,100 @@ export function generateLeaseYaml(rpNamespace, startdate, duration, reviewer) { `; } +/** + * Get the path for a lease file + * @param {string} repoRoot - Repository root path + * @param {string} orgName - Organization name + * @param {string} rpNamespace - Resource provider namespace + * @param {string|null} [serviceName] - Optional service name + * @returns {string} Full path to lease.yaml file + */ export function getLeasePath(repoRoot, orgName, rpNamespace, serviceName = null) { - const basePath = path.join(repoRoot, LEASE_BASE_PATH, orgName, rpNamespace); - if (serviceName) { - return path.join(basePath, serviceName, "lease.yaml"); - } - return path.join(basePath, "lease.yaml"); + const basePath = join(repoRoot, LEASE_BASE_PATH, orgName, rpNamespace); + return serviceName ? join(basePath, serviceName, "lease.yaml") : join(basePath, "lease.yaml"); } -function createLeaseFile(filePath, content, dryRun = false) { - if (dryRun) { +/** + * Create a lease file + * @param {string} filePath - Path to the file + * @param {string} content - File content + * @param {boolean} isDryRun - Whether this is a dry run + */ +function createLeaseFile(filePath, content, isDryRun) { + if (isDryRun) { console.log(`[DRY RUN] Would create: ${filePath}`); console.log(content); console.log("---"); return; } - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); } - if (fs.existsSync(filePath)) { + if (existsSync(filePath)) { console.warn(`Warning: File already exists, skipping: ${filePath}`); return; } - fs.writeFileSync(filePath, content, "utf-8"); + writeFileSync(filePath, content, "utf-8"); console.log(`Created: ${filePath}`); } -function processEntry(entry, args, repoRoot) { +/** + * Process a single entry + * @param {{orgName: string, rpNamespace: string, serviceNames: string[]}} entry + * @param {{startdate: string, duration: string, reviewer: string, dryRun: boolean}} options + * @param {string} repoRoot + */ +function processEntry(entry, options, repoRoot) { const { orgName, rpNamespace, serviceNames } = entry; - const { startdate, duration, reviewer, dryRun } = args; + const { startdate, duration, reviewer, dryRun: isDryRun } = options; try { validateOrgName(orgName); validateRpNamespace(rpNamespace); - const content = generateLeaseYaml( - rpNamespace, - startdate, - duration, - reviewer, - ); + const content = generateLeaseYaml(rpNamespace, startdate, duration, reviewer); if (serviceNames.length === 0) { - const leasePath = getLeasePath(repoRoot, orgName, rpNamespace); - createLeaseFile(leasePath, content, dryRun); + createLeaseFile(getLeasePath(repoRoot, orgName, rpNamespace), content, isDryRun); } else { for (const serviceName of serviceNames) { - const leasePath = getLeasePath( - repoRoot, - orgName, - rpNamespace, - serviceName, - ); - createLeaseFile(leasePath, content, dryRun); + createLeaseFile(getLeasePath(repoRoot, orgName, rpNamespace, serviceName), content, isDryRun); } } } catch (error) { - console.error( - `Error processing ${orgName}/${rpNamespace}: ${error.message}`, - ); + console.error(`Error processing ${orgName}/${rpNamespace}: ${error.message}`); } } +/** + * Run interactive mode + * @returns {Promise<{reviewer: string, startdate: string, duration: string, entries: Array}>} + */ async function promptInteractive() { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const question = (prompt) => - new Promise((resolve) => { - rl.question(prompt, resolve); - }); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve)); console.log("\nInteractive Lease File Generator"); console.log("=================================\n"); - const reviewer = await question( - "Enter reviewer GitHub alias (required, e.g., @githubUser): ", - ); + const reviewer = await question("Enter reviewer GitHub alias (required, e.g., @githubUser): "); if (!reviewer.trim()) { console.error("Error: Reviewer name is required"); rl.close(); process.exit(1); } - const startdateInput = await question( - `Enter start date [YYYY-MM-DD] (default: ${getTodayDate()}): `, - ); + const startdateInput = await question(`Enter start date [YYYY-MM-DD] (default: ${getTodayDate()}): `); const startdate = startdateInput.trim() || getTodayDate(); - const durationInput = await question( - `Enter duration [P#D] (default: ${DEFAULT_DURATION}): `, - ); + const durationInput = await question(`Enter duration [P#D] (default: ${DEFAULT_DURATION}): `); const duration = durationInput.trim() || DEFAULT_DURATION; - console.log( - "\nEnter resource provider entries (one per line, empty line to finish):", - ); + console.log("\nEnter resource provider entries (one per line, empty line to finish):"); console.log("Format: orgName, rpNamespace, [optional serviceNames]"); console.log("Example: storage, Microsoft.Storage"); console.log("Example: compute, Microsoft.Compute, [ComputeRP, DiskRP]\n"); @@ -376,145 +244,171 @@ async function promptInteractive() { while (true) { const line = await question("> "); if (!line.trim()) break; - const entry = parseInputLine(line); - if (entry) { - entries.push(entry); - } + if (entry) entries.push(entry); } rl.close(); - - return { - reviewer: reviewer.trim(), - startdate, - duration, - entries, - dryRun: false, - }; + return { reviewer: reviewer.trim(), startdate, duration, entries, dryRun: false }; } -export function getTodayDate() { - const today = new Date(); - return today.toISOString().split("T")[0]; -} +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); -async function main() { - let args = parseArgs(); +function usage() { + console.log(`Usage: +npx arm-lease-generate-lease-files --orgName --rpNamespace --reviewer [options] +npx arm-lease-generate-lease-files --input --reviewer [options] +npx arm-lease-generate-lease-files --interactive - if (args.interactive) { - const interactive = await promptInteractive(); - args.reviewer = interactive.reviewer; - args.startdate = interactive.startdate; - args.duration = interactive.duration; +Options: + --orgName Organization/service name (lowercase alphanumeric) + --rpNamespace Resource provider namespace (e.g., Microsoft.Storage) + --serviceName Comma-separated service groups (e.g., DiskRP,ComputeRP) + --reviewer Reviewer GitHub alias starting with @ (e.g., @githubUser) + --startdate Lease start date YYYY-MM-DD (default: today) + --duration Lease duration (default: P180D, max: P180D) + --input Input file with RP entries (one per line) + --repo-root Repository root path (auto-detected if not provided) + --dry-run Show what would be created without writing files + --interactive, -i Interactive mode with prompts + --help Show this help message - if (interactive.entries.length === 0) { - console.error("Error: No entries provided"); - return 1; - } +Input File Format: + - Without service groups: orgName, rpNamespace + - With service groups: orgName, rpNamespace, [serviceName1, serviceName2, ...] - try { - const repoRoot = args.repoRoot - ? path.resolve(args.repoRoot) - : findRepoRoot(); - const reviewer = validateReviewer(args.reviewer); - const startdate = validateStartDate(args.startdate); - const duration = validateDuration(args.duration); - - console.log(`\nRepository root: ${repoRoot}`); - console.log(`Reviewer: ${reviewer}`); - console.log(`Start date: ${startdate}`); - console.log(`Duration: ${duration}\n`); +Examples: + # Single RP + npx arm-lease-generate-lease-files --orgName storage --rpNamespace Microsoft.Storage --reviewer "@johndoe" - for (const entry of interactive.entries) { - processEntry( - entry, - { ...args, startdate, duration, reviewer }, - repoRoot, - ); - } + # With service groups + npx arm-lease-generate-lease-files --orgName compute --rpNamespace Microsoft.Compute --serviceName "DiskRP,ComputeRP" --reviewer "@johndoe" - console.log(`\nProcessed ${interactive.entries.length} entries`); - return 0; - } catch (error) { - console.error(`Error: ${error.message}`); - return 1; - } - } + # From file + npx arm-lease-fetch-resource-providers --output rps.txt + npx arm-lease-generate-lease-files --input rps.txt --reviewer "@johndoe" - if (!args.reviewer) { - console.error("Error: --reviewer is required"); - console.error("Use --help for usage information"); - return 1; - } + # Interactive mode + npx arm-lease-generate-lease-files --interactive`); +} - if (!args.startdate) { - args.startdate = getTodayDate(); +// Only run CLI if this file is executed directly (not imported) +if (process.argv[1] === __filename) { + const { + values: { + orgName: orgNameArg, + rpNamespace: rpNamespaceArg, + serviceName: serviceNameArg, + reviewer: reviewerArg, + startdate: startdateArg, + duration: durationArg, + input: inputArg, + "repo-root": repoRootArg, + "dry-run": dryRun, + interactive, + help, + }, + } = parseArgs({ + options: { + orgName: { type: "string", default: "" }, + rpNamespace: { type: "string", default: "" }, + serviceName: { type: "string", default: "" }, + reviewer: { type: "string", default: "" }, + startdate: { type: "string", default: "" }, + duration: { type: "string", default: DEFAULT_DURATION }, + input: { type: "string", default: "" }, + "repo-root": { type: "string", default: "" }, + "dry-run": { type: "boolean", default: false }, + interactive: { type: "boolean", short: "i", default: false }, + help: { type: "boolean", default: false }, + }, + allowPositionals: false, + }); + + if (help) { + usage(); + process.exit(0); } - try { - const repoRoot = args.repoRoot - ? path.resolve(args.repoRoot) - : findRepoRoot(); - const reviewer = validateReviewer(args.reviewer); - const startdate = validateStartDate(args.startdate); - const duration = validateDuration(args.duration); - - console.log(`Repository root: ${repoRoot}`); - console.log(`Reviewer: ${reviewer}`); - console.log(`Start date: ${startdate}`); - console.log(`Duration: ${duration}\n`); - - const entries = []; - - if (args.input) { - const inputPath = path.resolve(args.input); - if (!fs.existsSync(inputPath)) { - throw new Error(`Input file not found: ${inputPath}`); - } + async function main() { + try { + const repoRoot = repoRootArg ? resolve(repoRootArg) : findRepoRoot(resolve(__dirname, "../../../")); + let entries = []; + let reviewer = reviewerArg; + let startdate = startdateArg || getTodayDate(); + let duration = durationArg; + + if (interactive) { + const result = await promptInteractive(); + reviewer = result.reviewer; + startdate = result.startdate; + duration = result.duration; + entries = result.entries; + + if (entries.length === 0) { + console.error("Error: No entries provided"); + process.exit(1); + } + } else if (inputArg) { + if (!reviewer) { + console.error("Error: --reviewer is required"); + usage(); + process.exit(1); + } + + if (!existsSync(inputArg)) { + throw new Error(`Input file not found: ${inputArg}`); + } - const content = fs.readFileSync(inputPath, "utf-8"); - const lines = content.split("\n"); + const content = readFileSync(inputArg, "utf-8"); + for (const line of content.split("\n")) { + const entry = parseInputLine(line); + if (entry) entries.push(entry); + } - for (const line of lines) { - const entry = parseInputLine(line); - if (entry) { - entries.push(entry); + if (entries.length === 0) { + console.warn("Warning: No valid entries found in input file"); + process.exit(0); + } + } else if (orgNameArg && rpNamespaceArg) { + if (!reviewer) { + console.error("Error: --reviewer is required"); + usage(); + process.exit(1); } + + entries.push({ + orgName: orgNameArg, + rpNamespace: rpNamespaceArg, + serviceNames: serviceNameArg ? serviceNameArg.split(",").map((s) => s.trim()).filter(Boolean) : [], + }); + } else { + console.error("Error: Either --input, --interactive, or both --orgName and --rpNamespace are required"); + usage(); + process.exit(1); } - if (entries.length === 0) { - console.warn("Warning: No valid entries found in input file"); - return 0; + // Validate inputs + reviewer = validateReviewer(reviewer); + startdate = validateStartDate(startdate); + duration = validateDuration(duration); + + console.log(`Repository root: ${repoRoot}`); + console.log(`Reviewer: ${reviewer}`); + console.log(`Start date: ${startdate}`); + console.log(`Duration: ${duration}\n`); + + for (const entry of entries) { + processEntry(entry, { startdate, duration, reviewer, dryRun }, repoRoot); } - } else if (args.orgName && args.rpNamespace) { - entries.push({ - orgName: args.orgName, - rpNamespace: args.rpNamespace, - serviceNames: args.serviceNames, - }); - } else { - console.error( - "Error: Either --input or both --orgName and --rpNamespace are required", - ); - console.error("Use --help for usage information"); - return 1; - } - for (const entry of entries) { - processEntry(entry, { ...args, startdate, duration, reviewer }, repoRoot); + console.log(`\nProcessed ${entries.length} entries`); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); } - - console.log(`\nProcessed ${entries.length} entries`); - return 0; - } catch (error) { - console.error(`Error: ${error.message}`); - return 1; } -} -// Check if this module is being run directly -const __filename = fileURLToPath(import.meta.url); -if (process.argv[1] === __filename) { - main().then((code) => process.exit(code)); + main(); } From d0e617ab89cf34566e3058b6adaabf2a82f86c9e Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Fri, 1 May 2026 12:14:44 -0500 Subject: [PATCH 15/18] Fix find repo root failure --- .../cmd/arm-lease-fetch-resource-providers.js | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js index 382af84449fc..235370ebd18c 100644 --- a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js +++ b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js @@ -27,15 +27,34 @@ export function hasVersionDirectories(rpPath) { } /** - * Find the repository root by looking for the specification directory + * Check if a directory looks like the repository root + * @param {string} dir - Directory to check + * @returns {boolean} True if it looks like repo root + */ +function looksLikeRepoRoot(dir) { + return ( + existsSync(join(dir, ".git")) || + existsSync(join(dir, "specification")) || + existsSync(join(dir, ".github")) + ); +} + +/** + * Find the repository root by looking for repo markers (.git, specification, .github) + * Supports sparse checkouts in CI by checking GITHUB_WORKSPACE first * @param {string} [startPath] - Starting path to search from * @returns {string} The repository root path * @throws {Error} If repository root cannot be found */ export function findRepoRoot(startPath = process.cwd()) { + const workspace = process.env.GITHUB_WORKSPACE; + if (workspace && looksLikeRepoRoot(workspace)) { + return workspace; + } + let current = resolve(startPath); for (let i = 0; i < 20; i++) { - if (existsSync(join(current, "specification"))) return current; + if (looksLikeRepoRoot(current)) return current; const parent = dirname(current); if (parent === current) break; current = parent; From d6a161a142da974dd4ec1a905c089f9a2ae33147 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Fri, 1 May 2026 12:24:53 -0500 Subject: [PATCH 16/18] Fix tests --- .../cmd/arm-lease-fetch-resource-providers.js | 3 +- ...arm-lease-fetch-resource-providers.test.js | 28 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js index 235370ebd18c..60e46e36e55e 100644 --- a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js +++ b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js @@ -80,7 +80,8 @@ export function findResourceProviders(repoRoot, withServiceNames = false) { const results = []; const specDir = join(repoRoot, "specification"); if (!existsSync(specDir)) { - throw new Error(`Specification directory not found: ${specDir}`); + // In sparse checkouts (CI), specification/ may not exist. Return empty list. + return []; } for (const orgName of readdirSync(specDir)) { diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js index 1e5b4fadd80f..250811f7aa4a 100644 --- a/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js @@ -13,12 +13,26 @@ import { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// Check if specification directory exists (may not exist in sparse checkouts) +const hasSpecificationDir = () => { + try { + const repoRoot = findRepoRoot(__dirname); + return fs.existsSync(path.join(repoRoot, "specification")); + } catch { + return false; + } +}; + describe("fetch-resource-providers", () => { describe("findRepoRoot", () => { it("finds the repository root", () => { const repoRoot = findRepoRoot(__dirname); expect(repoRoot.endsWith("azure-rest-api-specs")).toBe(true); - expect(fs.existsSync(path.join(repoRoot, "specification"))).toBe(true); + // In sparse checkouts, .github exists but specification may not + expect( + fs.existsSync(path.join(repoRoot, "specification")) || + fs.existsSync(path.join(repoRoot, ".github")) + ).toBe(true); }); }); @@ -27,6 +41,12 @@ describe("fetch-resource-providers", () => { const repoRoot = findRepoRoot(__dirname); const rps = findResourceProviders(repoRoot, false); + // In sparse checkouts, specification/ may not exist, so rps could be empty + if (!hasSpecificationDir()) { + expect(rps).toEqual([]); + return; + } + expect(rps.length).toBeGreaterThan(0); expect(rps.every((rp) => rp.rpNamespace && rp.orgName && rp.path)).toBe(true); expect(rps.every((rp) => !rp.serviceNames)).toBe(true); @@ -40,6 +60,12 @@ describe("fetch-resource-providers", () => { const repoRoot = findRepoRoot(__dirname); const rps = findResourceProviders(repoRoot, true); + // In sparse checkouts, specification/ may not exist, so rps could be empty + if (!hasSpecificationDir()) { + expect(rps).toEqual([]); + return; + } + expect(rps.length).toBeGreaterThan(0); expect(rps.every((rp) => rp.rpNamespace && rp.orgName && rp.path && rp.serviceNames)).toBe( true, From 41908697b83ccd7864105cb1240b7756e9cae670 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Fri, 1 May 2026 14:47:42 -0500 Subject: [PATCH 17/18] Fix WF failure --- .../cmd/arm-lease-fetch-resource-providers.js | 8 ++++---- .../cmd/arm-lease-generate-lease-files.js | 16 +++++++++------- .../arm-lease-fetch-resource-providers.test.js | 6 ++++-- .../arm-lease-generate-lease-files.test.js | 18 +++++++++--------- 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js index 60e46e36e55e..f669f4ed3b1d 100644 --- a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js +++ b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js @@ -143,7 +143,7 @@ export function formatOutput(rps, fmt, withSN) { const rows = withSN ? rps.map( (r) => - `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.serviceNames.join(", ")}`, + `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${/** @type {string[]} */ (r.serviceNames).join(", ")}`, ) : rps.map( (r) => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.path}`, @@ -152,7 +152,7 @@ export function formatOutput(rps, fmt, withSN) { } return withSN - ? rps.map((r) => `${r.orgName}, ${r.rpNamespace}, [${r.serviceNames.join(", ")}]`).join("\n") + ? rps.map((r) => `${r.orgName}, ${r.rpNamespace}, [${/** @type {string[]} */ (r.serviceNames).join(", ")}]`).join("\n") : rps.map((r) => `${r.orgName}, ${r.rpNamespace}`).join("\n"); } @@ -221,7 +221,7 @@ if (process.argv[1] === __filename) { if (count) { outputText = String(rps.length); } else { - outputText = formatOutput(rps, format, withServiceGroups); + outputText = formatOutput(rps, /** @type {"list"|"json"|"table"} */ (format), withServiceGroups); if (format !== "json") { outputText += `\n\nTotal: ${rps.length} resource provider(s) ${withServiceGroups ? "with" : "without"} serviceNames`; } @@ -234,7 +234,7 @@ if (process.argv[1] === __filename) { console.log(outputText); } } catch (error) { - console.error(`Error: ${error.message}`); + console.error(`Error: ${/** @type {Error} */ (error).message}`); process.exit(1); } } diff --git a/.github/workflows/cmd/arm-lease-generate-lease-files.js b/.github/workflows/cmd/arm-lease-generate-lease-files.js index d4a3a76b3112..3157cdd2c790 100644 --- a/.github/workflows/cmd/arm-lease-generate-lease-files.js +++ b/.github/workflows/cmd/arm-lease-generate-lease-files.js @@ -108,7 +108,7 @@ export function parseInputLine(line) { line = line.trim(); if (!line || line.startsWith("#")) return null; - const match = line.match(/^([^,]+),\s*([^,\[]+)(?:,\s*\[([^\]]+)\])?$/); + const match = line.match(/^([^,]+),\s*([^,[]+)(?:,\s*\[([^\]]+)\])?$/); if (!match) { console.warn(`Skipping invalid line: ${line}`); return null; @@ -138,7 +138,6 @@ export function generateLeaseYaml(rpNamespace, startdate, duration, reviewer) { startdate: "${startdate}" duration: ${duration} reviewer: "${reviewer}" - `; } @@ -207,17 +206,18 @@ function processEntry(entry, options, repoRoot) { } } } catch (error) { - console.error(`Error processing ${orgName}/${rpNamespace}: ${error.message}`); + console.error(`Error processing ${orgName}/${rpNamespace}: ${/** @type {Error} */ (error).message}`); } } /** * Run interactive mode - * @returns {Promise<{reviewer: string, startdate: string, duration: string, entries: Array}>} + * @returns {Promise<{reviewer: string, startdate: string, duration: string, entries: Array<{orgName: string, rpNamespace: string, serviceNames: string[]}>, dryRun: boolean}>} */ async function promptInteractive() { const rl = createInterface({ input: process.stdin, output: process.stdout }); - const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve)); + /** @param {string} prompt */ + const question = (prompt) => /** @type {Promise} */ (new Promise((resolve) => rl.question(prompt, resolve))); console.log("\nInteractive Lease File Generator"); console.log("=================================\n"); @@ -240,6 +240,7 @@ async function promptInteractive() { console.log("Example: storage, Microsoft.Storage"); console.log("Example: compute, Microsoft.Compute, [ComputeRP, DiskRP]\n"); + /** @type {Array<{orgName: string, rpNamespace: string, serviceNames: string[]}>} */ const entries = []; while (true) { const line = await question("> "); @@ -334,6 +335,7 @@ if (process.argv[1] === __filename) { async function main() { try { const repoRoot = repoRootArg ? resolve(repoRootArg) : findRepoRoot(resolve(__dirname, "../../../")); + /** @type {Array<{orgName: string, rpNamespace: string, serviceNames: string[]}>} */ let entries = []; let reviewer = reviewerArg; let startdate = startdateArg || getTodayDate(); @@ -405,10 +407,10 @@ if (process.argv[1] === __filename) { console.log(`\nProcessed ${entries.length} entries`); } catch (error) { - console.error(`Error: ${error.message}`); + console.error(`Error: ${/** @type {Error} */ (error).message}`); process.exit(1); } } - main(); + void main(); } diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js index 250811f7aa4a..a3abe514fe0f 100644 --- a/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js @@ -74,8 +74,8 @@ describe("fetch-resource-providers", () => { const compute = rps.find((rp) => rp.rpNamespace === "Microsoft.Compute"); expect(compute).toBeDefined(); - expect(compute.orgName).toBe("compute"); - expect(compute.serviceNames.includes("Compute")).toBe(true); + expect(compute?.orgName).toBe("compute"); + expect(compute?.serviceNames?.includes("Compute")).toBe(true); expect(rps.some((rp) => rp.rpNamespace === "Microsoft.Storage")).toBe(false); }); }); @@ -103,9 +103,11 @@ describe("fetch-resource-providers", () => { it("formats JSON output", () => { const output = formatOutput(rpsWithout, "json", false); + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); expect(parsed[0].rpNamespace).toBe("Microsoft.Test"); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ }); it("formats table output", () => { diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js index 4af7f527156f..c7e1ce2df3fd 100644 --- a/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js @@ -17,18 +17,18 @@ describe("generate-lease-files", () => { describe("parseInputLine", () => { it("parses line without service names", () => { const result = parseInputLine("storage, Microsoft.Storage"); - expect(result.orgName).toBe("storage"); - expect(result.rpNamespace).toBe("Microsoft.Storage"); - expect(result.serviceNames).toHaveLength(0); + expect(result?.orgName).toBe("storage"); + expect(result?.rpNamespace).toBe("Microsoft.Storage"); + expect(result?.serviceNames).toHaveLength(0); }); it("parses line with service names", () => { const result = parseInputLine("compute, Microsoft.Compute, [ComputeRP, DiskRP]"); - expect(result.orgName).toBe("compute"); - expect(result.rpNamespace).toBe("Microsoft.Compute"); - expect(result.serviceNames).toHaveLength(2); - expect(result.serviceNames[0]).toBe("ComputeRP"); - expect(result.serviceNames[1]).toBe("DiskRP"); + expect(result?.orgName).toBe("compute"); + expect(result?.rpNamespace).toBe("Microsoft.Compute"); + expect(result?.serviceNames).toHaveLength(2); + expect(result?.serviceNames[0]).toBe("ComputeRP"); + expect(result?.serviceNames[1]).toBe("DiskRP"); }); it("returns null for empty line", () => { @@ -48,7 +48,7 @@ describe("generate-lease-files", () => { expect(yaml).toContain('startdate: "2026-06-01"'); expect(yaml).toContain("duration: P180D"); expect(yaml).toContain('reviewer: "@johnDoe"'); - expect(yaml.endsWith("\n\n")).toBe(true); + expect(yaml.endsWith("\n")).toBe(true); }); it("does not use duration-days field", () => { From f5925ede8259ab398ef83cca1dfc8d5babd36ac9 Mon Sep 17 00:00:00 2001 From: Tejaswi Salaigari Date: Fri, 1 May 2026 14:54:08 -0500 Subject: [PATCH 18/18] Prettier check --- .../cmd/arm-lease-fetch-resource-providers.js | 21 ++++++--- .../cmd/arm-lease-generate-lease-files.js | 43 ++++++++++++++----- ...arm-lease-fetch-resource-providers.test.js | 2 +- .../arm-lease-generate-lease-files.test.js | 8 ++-- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js index f669f4ed3b1d..a685572dd5e9 100644 --- a/.github/workflows/cmd/arm-lease-fetch-resource-providers.js +++ b/.github/workflows/cmd/arm-lease-fetch-resource-providers.js @@ -145,14 +145,17 @@ export function formatOutput(rps, fmt, withSN) { (r) => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${/** @type {string[]} */ (r.serviceNames).join(", ")}`, ) - : rps.map( - (r) => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.path}`, - ); + : rps.map((r) => `${r.orgName.padEnd(maxOrg)} ${r.rpNamespace.padEnd(maxRp)} ${r.path}`); return [header, sep, ...rows].join("\n"); } return withSN - ? rps.map((r) => `${r.orgName}, ${r.rpNamespace}, [${/** @type {string[]} */ (r.serviceNames).join(", ")}]`).join("\n") + ? rps + .map( + (r) => + `${r.orgName}, ${r.rpNamespace}, [${/** @type {string[]} */ (r.serviceNames).join(", ")}]`, + ) + .join("\n") : rps.map((r) => `${r.orgName}, ${r.rpNamespace}`).join("\n"); } @@ -214,14 +217,20 @@ if (process.argv[1] === __filename) { } try { - const repoRoot = repoRootArg ? resolve(repoRootArg) : findRepoRoot(resolve(__dirname, "../../../")); + const repoRoot = repoRootArg + ? resolve(repoRootArg) + : findRepoRoot(resolve(__dirname, "../../../")); const rps = findResourceProviders(repoRoot, withServiceGroups); let outputText; if (count) { outputText = String(rps.length); } else { - outputText = formatOutput(rps, /** @type {"list"|"json"|"table"} */ (format), withServiceGroups); + outputText = formatOutput( + rps, + /** @type {"list"|"json"|"table"} */ (format), + withServiceGroups, + ); if (format !== "json") { outputText += `\n\nTotal: ${rps.length} resource provider(s) ${withServiceGroups ? "with" : "without"} serviceNames`; } diff --git a/.github/workflows/cmd/arm-lease-generate-lease-files.js b/.github/workflows/cmd/arm-lease-generate-lease-files.js index 3157cdd2c790..b5634434abcd 100644 --- a/.github/workflows/cmd/arm-lease-generate-lease-files.js +++ b/.github/workflows/cmd/arm-lease-generate-lease-files.js @@ -1,6 +1,6 @@ -import { createInterface } from "readline"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join, resolve } from "path"; +import { createInterface } from "readline"; import { fileURLToPath } from "url"; import { parseArgs } from "util"; import { findRepoRoot } from "./arm-lease-fetch-resource-providers.js"; @@ -94,7 +94,9 @@ export function validateReviewer(reviewer) { } const trimmed = reviewer.trim(); if (!trimmed.startsWith("@") || trimmed.length <= 1) { - throw new Error(`Reviewer must be a GitHub alias starting with @ (e.g., @githubUser). Got: ${reviewer}`); + throw new Error( + `Reviewer must be a GitHub alias starting with @ (e.g., @githubUser). Got: ${reviewer}`, + ); } return trimmed; } @@ -118,7 +120,10 @@ export function parseInputLine(line) { const rpNamespace = match[2].trim(); const serviceNamesStr = match[3]; const serviceNames = serviceNamesStr - ? serviceNamesStr.split(",").map((s) => s.trim()).filter(Boolean) + ? serviceNamesStr + .split(",") + .map((s) => s.trim()) + .filter(Boolean) : []; return { orgName, rpNamespace, serviceNames }; @@ -202,11 +207,17 @@ function processEntry(entry, options, repoRoot) { createLeaseFile(getLeasePath(repoRoot, orgName, rpNamespace), content, isDryRun); } else { for (const serviceName of serviceNames) { - createLeaseFile(getLeasePath(repoRoot, orgName, rpNamespace, serviceName), content, isDryRun); + createLeaseFile( + getLeasePath(repoRoot, orgName, rpNamespace, serviceName), + content, + isDryRun, + ); } } } catch (error) { - console.error(`Error processing ${orgName}/${rpNamespace}: ${/** @type {Error} */ (error).message}`); + console.error( + `Error processing ${orgName}/${rpNamespace}: ${/** @type {Error} */ (error).message}`, + ); } } @@ -217,7 +228,8 @@ function processEntry(entry, options, repoRoot) { async function promptInteractive() { const rl = createInterface({ input: process.stdin, output: process.stdout }); /** @param {string} prompt */ - const question = (prompt) => /** @type {Promise} */ (new Promise((resolve) => rl.question(prompt, resolve))); + const question = (prompt) => + /** @type {Promise} */ (new Promise((resolve) => rl.question(prompt, resolve))); console.log("\nInteractive Lease File Generator"); console.log("=================================\n"); @@ -229,7 +241,9 @@ async function promptInteractive() { process.exit(1); } - const startdateInput = await question(`Enter start date [YYYY-MM-DD] (default: ${getTodayDate()}): `); + const startdateInput = await question( + `Enter start date [YYYY-MM-DD] (default: ${getTodayDate()}): `, + ); const startdate = startdateInput.trim() || getTodayDate(); const durationInput = await question(`Enter duration [P#D] (default: ${DEFAULT_DURATION}): `); @@ -334,7 +348,9 @@ if (process.argv[1] === __filename) { async function main() { try { - const repoRoot = repoRootArg ? resolve(repoRootArg) : findRepoRoot(resolve(__dirname, "../../../")); + const repoRoot = repoRootArg + ? resolve(repoRootArg) + : findRepoRoot(resolve(__dirname, "../../../")); /** @type {Array<{orgName: string, rpNamespace: string, serviceNames: string[]}>} */ let entries = []; let reviewer = reviewerArg; @@ -383,10 +399,17 @@ if (process.argv[1] === __filename) { entries.push({ orgName: orgNameArg, rpNamespace: rpNamespaceArg, - serviceNames: serviceNameArg ? serviceNameArg.split(",").map((s) => s.trim()).filter(Boolean) : [], + serviceNames: serviceNameArg + ? serviceNameArg + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [], }); } else { - console.error("Error: Either --input, --interactive, or both --orgName and --rpNamespace are required"); + console.error( + "Error: Either --input, --interactive, or both --orgName and --rpNamespace are required", + ); usage(); process.exit(1); } diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js index a3abe514fe0f..2b53e13632e9 100644 --- a/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-fetch-resource-providers.test.js @@ -31,7 +31,7 @@ describe("fetch-resource-providers", () => { // In sparse checkouts, .github exists but specification may not expect( fs.existsSync(path.join(repoRoot, "specification")) || - fs.existsSync(path.join(repoRoot, ".github")) + fs.existsSync(path.join(repoRoot, ".github")), ).toBe(true); }); }); diff --git a/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js b/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js index c7e1ce2df3fd..28ad476cba7d 100644 --- a/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js +++ b/.github/workflows/test/arm-lease-validation/arm-lease-generate-lease-files.test.js @@ -2,15 +2,15 @@ import path from "path"; import { describe, expect, it } from "vitest"; import { - parseInputLine, generateLeaseYaml, getLeasePath, - validateStartDate, + getTodayDate, + parseInputLine, validateDuration, - validateRpNamespace, validateOrgName, validateReviewer, - getTodayDate, + validateRpNamespace, + validateStartDate, } from "../../cmd/arm-lease-generate-lease-files.js"; describe("generate-lease-files", () => {