scripts/changeset-prompt.mjs
scripts/changeset-prompt.mjsBrowse 7 files
3,667 tokens
14,331 bytes
Token encoding: o200k_base
Snapshot 506f736
← Back to SKILL.md
1#!/usr/bin/env node2 3import fs from 'fs';4import path from 'path';5import { execSync, spawnSync } from 'child_process';6 7const { console, process } = globalThis;8 9const EXEC_MAX_BUFFER = Number(10 process.env.CHANGESET_MAX_BUFFER_BYTES || 50 * 1024 * 1024,11);12const ALLOWED_PACKAGES = [13 '@openai/agents',14 '@openai/agents-core',15 '@openai/agents-extensions',16 '@openai/agents-openai',17 '@openai/agents-realtime',18];19 20const MAX_DIFF_CHARS = Number(process.env.CHANGESET_MAX_DIFF_CHARS || 12000);21const PROMPT_PATH = path.join(22 '.agents',23 'skills',24 'changeset-validation',25 'references',26 'validation-prompt.md',27);28 29function printUsage() {30 console.log(`changeset-prompt31 32Usage:33 pnpm changeset:validate-prompt -- [--base <ref>] [--head <ref>] [--ci] [--output <path>]34 35Options:36 --base <ref> Base ref or SHA (default: origin/main if available, else main).37 --head <ref> Head ref or SHA (default: HEAD).38 --ci Use CI context (PR body, no working tree diffs).39 --output <path> Write the generated prompt to a file instead of stdout.40 --help Show this help text.41`);42}43 44function run(cmd, options = {}) {45 return execSync(cmd, {46 encoding: 'utf8',47 stdio: ['ignore', 'pipe', 'pipe'],48 maxBuffer: EXEC_MAX_BUFFER,49 ...options,50 }).trim();51}52 53function runOptional(cmd) {54 try {55 return run(cmd);56 } catch (_error) {57 return '';58 }59}60 61function parseArgs(argv) {62 const options = {63 base: null,64 head: null,65 ci: process.env.GITHUB_ACTIONS === 'true',66 output: null,67 };68 69 for (let i = 0; i < argv.length; i += 1) {70 const arg = argv[i];71 if (arg === '--help') {72 options.help = true;73 continue;74 }75 if (arg === '--base') {76 options.base = argv[i + 1];77 i += 1;78 continue;79 }80 if (arg === '--head') {81 options.head = argv[i + 1];82 i += 1;83 continue;84 }85 if (arg === '--ci') {86 options.ci = true;87 continue;88 }89 if (arg === '--output') {90 options.output = argv[i + 1];91 i += 1;92 continue;93 }94 }95 96 return options;97}98 99function parseNameStatus(text) {100 if (!text) return [];101 const lines = text.split(/\r?\n/).filter(Boolean);102 const entries = [];103 for (const line of lines) {104 const parts = line.split('\t');105 const status = parts[0];106 if (!status) continue;107 if (status.startsWith('R') || status.startsWith('C')) {108 if (parts[1]) entries.push({ path: parts[1], status });109 if (parts[2]) entries.push({ path: parts[2], status });110 } else if (parts[1]) {111 entries.push({ path: parts[1], status });112 }113 }114 return entries;115}116 117function isChangesetFile(filePath) {118 if (!filePath.startsWith('.changeset/')) return false;119 if (!filePath.endsWith('.md')) return false;120 return path.basename(filePath) !== 'README.md';121}122 123function readFileSafe(filePath) {124 try {125 return fs.readFileSync(filePath, 'utf8');126 } catch (_error) {127 return null;128 }129}130 131function truncateText(text, maxChars) {132 if (text.length <= maxChars) return text;133 return `${text.slice(0, maxChars)}\n... (truncated ${text.length - maxChars} chars)`;134}135 136function getDiffNoIndex(filePath) {137 const result = spawnSync(138 'git',139 ['diff', '--no-index', '--', '/dev/null', filePath],140 {141 encoding: 'utf8',142 },143 );144 return result.stdout.trim();145}146 147function readFileFromGit(ref, filePath) {148 const result = spawnSync('git', ['show', `${ref}:${filePath}`], {149 encoding: 'utf8',150 });151 if (result.status !== 0) return null;152 return result.stdout;153}154 155function readFileAtRefOrWorktree(ref, filePath, preferWorkingTree = false) {156 if (preferWorkingTree) {157 const contents = readFileSafe(filePath);158 if (contents !== null) return contents;159 }160 return readFileFromGit(ref, filePath);161}162 163function parseJsonSafe(contents) {164 if (!contents) return null;165 try {166 return JSON.parse(contents);167 } catch (_error) {168 return null;169 }170}171 172function cloneJsonValue(value) {173 return JSON.parse(JSON.stringify(value));174}175 176function stripReleaseOnlyPackageJsonFields(packageJson) {177 if (!packageJson || typeof packageJson !== 'object') return packageJson;178 const normalized = cloneJsonValue(packageJson);179 delete normalized.version;180 return normalized;181}182 183function isReleaseManagedPackageFile(filePath, dir) {184 return (185 filePath === `packages/${dir}/CHANGELOG.md` ||186 filePath === `packages/${dir}/package.json` ||187 filePath === `packages/${dir}/src/metadata.ts`188 );189}190 191function hasMeaningfulPackageJsonChanges({192 dir,193 baseSha,194 headSha,195 includeWorkingTree,196}) {197 const filePath = `packages/${dir}/package.json`;198 const basePackageJson = parseJsonSafe(readFileFromGit(baseSha, filePath));199 const headPackageJson = parseJsonSafe(200 readFileAtRefOrWorktree(headSha, filePath, includeWorkingTree),201 );202 203 if (!basePackageJson || !headPackageJson) {204 return true;205 }206 207 return (208 JSON.stringify(stripReleaseOnlyPackageJsonFields(basePackageJson)) !==209 JSON.stringify(stripReleaseOnlyPackageJsonFields(headPackageJson))210 );211}212 213function collectRelevantPackageDirs({214 changedPackageDirs,215 packageFilesByDir,216 baseSha,217 headSha,218 includeWorkingTree,219}) {220 const relevantDirs = new Set();221 222 for (const dir of changedPackageDirs) {223 const files = packageFilesByDir.get(dir) || [];224 const onlyReleaseManagedFiles =225 files.length > 0 &&226 files.every((filePath) => isReleaseManagedPackageFile(filePath, dir));227 const meaningfulPackageJsonChanges = files.includes(228 `packages/${dir}/package.json`,229 )230 ? hasMeaningfulPackageJsonChanges({231 dir,232 baseSha,233 headSha,234 includeWorkingTree,235 })236 : false;237 238 if (onlyReleaseManagedFiles && !meaningfulPackageJsonChanges) {239 continue;240 }241 242 relevantDirs.add(dir);243 }244 245 return relevantDirs;246}247 248function readEventPayload() {249 const eventPath = process.env.GITHUB_EVENT_PATH;250 if (!eventPath) return null;251 const contents = readFileSafe(eventPath);252 if (!contents) return null;253 try {254 return JSON.parse(contents);255 } catch (_error) {256 return null;257 }258}259 260function renderPrompt(template, data) {261 return template262 .replaceAll('{{ALLOWED_PACKAGES}}', data.allowedPackages)263 .replaceAll('{{CHANGED_PACKAGES}}', data.changedPackages)264 .replaceAll('{{CHANGED_FILES}}', data.changedFiles)265 .replaceAll('{{CHANGESET_FILES}}', data.changesetFiles)266 .replaceAll('{{PR_BODY}}', data.prBody)267 .replaceAll('{{PR_LABELS}}', data.prLabels)268 .replaceAll('{{PACKAGE_DIFF}}', data.packageDiff)269 .replaceAll('{{UNKNOWN_PACKAGE_DIRS}}', data.unknownPackageDirs);270}271 272function formatChangesetFiles(entries) {273 if (entries.length === 0) return '(none)';274 return entries275 .map((entry) => {276 const header = `File: ${entry.path} (${entry.status || 'unknown'})`;277 const content = entry.content ? entry.content.trimEnd() : '(missing)';278 return `${header}\n\n\`\`\`md\n${content}\n\`\`\``;279 })280 .join('\n\n');281}282 283function writeOutputFile(outputPath, prompt) {284 const dir = path.dirname(outputPath);285 fs.mkdirSync(dir, { recursive: true });286 fs.writeFileSync(outputPath, `${prompt.trim()}\n`, 'utf8');287 console.log(`Wrote prompt to ${outputPath}.`);288}289 290async function main() {291 const options = parseArgs(process.argv.slice(2));292 if (options.help) {293 printUsage();294 process.exit(0);295 }296 297 const repoRoot = run('git rev-parse --show-toplevel');298 process.chdir(repoRoot);299 300 const eventPayload = readEventPayload();301 const eventBaseSha = eventPayload?.pull_request?.base?.sha;302 const eventHeadSha = eventPayload?.pull_request?.head?.sha;303 304 const baseRef =305 options.base ||306 eventBaseSha ||307 (runOptional('git rev-parse --verify origin/main')308 ? 'origin/main'309 : 'main');310 const headRef = options.head || eventHeadSha || 'HEAD';311 312 let baseSha;313 let headSha;314 try {315 headSha = run(`git rev-parse ${headRef}`);316 baseSha = run(`git merge-base ${baseRef} ${headRef}`);317 } catch (error) {318 console.error(`Failed to resolve git refs: ${error.message}`);319 process.exit(1);320 }321 322 const includeWorkingTree = !options.ci;323 const changes = new Map();324 const committedDiff = runOptional(325 `git diff --name-status ${baseSha} ${headSha}`,326 );327 for (const entry of parseNameStatus(committedDiff)) {328 changes.set(entry.path, entry.status);329 }330 331 if (includeWorkingTree) {332 const staged = runOptional('git diff --name-status --cached');333 const unstaged = runOptional('git diff --name-status');334 for (const entry of parseNameStatus(staged)) {335 changes.set(entry.path, entry.status);336 }337 for (const entry of parseNameStatus(unstaged)) {338 changes.set(entry.path, entry.status);339 }340 const untracked = runOptional('git ls-files --others --exclude-standard');341 for (const line of untracked.split(/\r?\n/).filter(Boolean)) {342 changes.set(line, 'A');343 }344 }345 346 const packageDirs = fs347 .readdirSync('packages', { withFileTypes: true })348 .filter((dirent) => dirent.isDirectory())349 .map((dirent) => dirent.name);350 351 const packageNameByDir = new Map();352 for (const dir of packageDirs) {353 const packageJsonPath = path.join('packages', dir, 'package.json');354 const contents = readFileSafe(packageJsonPath);355 if (!contents) continue;356 try {357 const parsed = JSON.parse(contents);358 if (parsed?.name) {359 packageNameByDir.set(dir, parsed.name);360 }361 } catch (_error) {362 console.error(`Failed to parse ${packageJsonPath}.`);363 process.exit(1);364 }365 }366 367 const changedPackageDirs = new Set();368 const packageFilesByDir = new Map();369 const unknownPackageDirs = new Set();370 for (const filePath of changes.keys()) {371 if (!filePath.startsWith('packages/')) continue;372 const parts = filePath.split('/');373 const dir = parts[1];374 if (!dir) continue;375 changedPackageDirs.add(dir);376 const files = packageFilesByDir.get(dir) || [];377 files.push(filePath);378 packageFilesByDir.set(dir, files);379 const packageName = packageNameByDir.get(dir);380 if (!packageName) {381 unknownPackageDirs.add(dir);382 }383 }384 385 const relevantPackageDirs = collectRelevantPackageDirs({386 changedPackageDirs,387 packageFilesByDir,388 baseSha,389 headSha,390 includeWorkingTree,391 });392 const changedPackages = new Set(393 [...relevantPackageDirs]394 .map((dir) => packageNameByDir.get(dir))395 .filter(Boolean),396 );397 398 const changesetPaths = [...changes.keys()].filter((filePath) =>399 isChangesetFile(filePath),400 );401 const changesetEntries = changesetPaths402 .map((filePath) => {403 const status = changes.get(filePath) || 'unknown';404 if (status.startsWith('D')) return null;405 const content =406 readFileSafe(filePath) || readFileFromGit(headSha, filePath);407 if (!content) {408 if (status.startsWith('R') || status.startsWith('C')) return null;409 }410 return { path: filePath, status, content };411 })412 .filter(Boolean);413 414 const diffSections = [];415 const relevantPackagePaths = [...relevantPackageDirs].map(416 (dir) => `packages/${dir}`,417 );418 const committedPackageDiff =419 relevantPackagePaths.length > 0420 ? runOptional(421 `git diff ${baseSha} ${headSha} -- ${relevantPackagePaths.join(' ')}`,422 )423 : '';424 if (committedPackageDiff) {425 diffSections.push(`Committed diff (packages):\n${committedPackageDiff}`);426 }427 428 if (includeWorkingTree) {429 const stagedPackageDiff =430 relevantPackagePaths.length > 0431 ? runOptional(`git diff --cached -- ${relevantPackagePaths.join(' ')}`)432 : '';433 if (stagedPackageDiff && relevantPackageDirs.size > 0) {434 diffSections.push(`Staged diff (packages):\n${stagedPackageDiff}`);435 }436 const unstagedPackageDiff =437 relevantPackagePaths.length > 0438 ? runOptional(`git diff -- ${relevantPackagePaths.join(' ')}`)439 : '';440 if (unstagedPackageDiff && relevantPackageDirs.size > 0) {441 diffSections.push(`Unstaged diff (packages):\n${unstagedPackageDiff}`);442 }443 444 const untrackedPackageDiffs = [];445 for (const filePath of changes.keys()) {446 if (!filePath.startsWith('packages/')) continue;447 const dir = filePath.split('/')[1];448 if (!dir || !relevantPackageDirs.has(dir)) continue;449 if (!fs.existsSync(filePath)) continue;450 if (451 !runOptional(`git ls-files --others --exclude-standard -- ${filePath}`)452 )453 continue;454 const diff = getDiffNoIndex(filePath);455 if (diff) untrackedPackageDiffs.push(diff);456 }457 if (untrackedPackageDiffs.length > 0) {458 diffSections.push(459 `Untracked diff (packages):\n${untrackedPackageDiffs.join('\n')}`,460 );461 }462 }463 464 const packageDiff = truncateText(465 diffSections.join('\n\n') || '(no package diff provided)',466 MAX_DIFF_CHARS,467 );468 const prBody = options.ci469 ? eventPayload?.pull_request?.body || '(none)'470 : '(not provided)';471 const prLabels = options.ci472 ? eventPayload?.pull_request?.labels473 ?.map((label) =>474 typeof label === 'string' ? label : label?.name || '',475 )476 .filter(Boolean)477 .sort()478 .join(', ') || '(none)'479 : '(not provided)';480 481 const promptTemplate = readFileSafe(PROMPT_PATH);482 if (!promptTemplate) {483 console.error(`Prompt template not found at ${PROMPT_PATH}.`);484 process.exit(1);485 }486 487 const prompt = renderPrompt(promptTemplate, {488 allowedPackages: ALLOWED_PACKAGES.join(', '),489 changedPackages: [...changedPackages].sort().join(', ') || '(none)',490 changedFiles:491 [...changes.keys()]492 .filter((filePath) => {493 if (!filePath.startsWith('packages/')) return true;494 const dir = filePath.split('/')[1];495 return Boolean(dir && relevantPackageDirs.has(dir));496 })497 .sort()498 .join('\n') || '(none)',499 changesetFiles: formatChangesetFiles(changesetEntries),500 prBody,501 prLabels,502 packageDiff,503 unknownPackageDirs: [...unknownPackageDirs].sort().join(', ') || '(none)',504 });505 506 if (options.output) {507 writeOutputFile(options.output, prompt);508 return;509 }510 511 console.log(prompt.trim());512}513 514main().catch((error) => {515 console.error(`changeset-prompt failed: ${error.message}`);516 process.exit(1);517});518