import fs from 'fs/promises' import { appendFileSync } from 'fs' import path from 'path' import { mkdirp } from 'mkdirp' import yaml from 'js-yaml' import { execSync } from 'child_process' import { getContents, hasMatchingRef } from '@/workflows/git-utils' import { allVersions } from '@/versions/lib/all-versions' import processPreviews from './utils/process-previews' import processUpcomingChanges from './utils/process-upcoming-changes' import processSchemas from './utils/process-schemas' import { prependDatedEntry, createChangelogEntry, getIgnoredChangesSummary, } from './build-changelog' // Type definitions interface GitHubRepoOptions { owner: string repo: string ref?: string path?: string } interface IgnoredChange { version: string totalCount: number types: Array<{ type: string }> } const graphqlStaticDir = 'src/graphql/data' const dataFilenames = JSON.parse( await fs.readFile('src/graphql/scripts/utils/data-filenames.json', 'utf8'), ) // check for required PAT if (!process.env.GITHUB_TOKEN) { throw new Error('Error! You must have a GITHUB_TOKEN set in an .env file to run this script.') } const versionsToBuild = Object.keys(allVersions) main() const allIgnoredChanges: IgnoredChange[] = [] async function main() { for (const version of versionsToBuild) { // Get the relevant GraphQL name for the current version // For example, free-pro-team@latest corresponds to dotcom, // enterprise-server@2.22 corresponds to ghes-2.22. const graphqlVersion = allVersions[version].openApiVersionName // 1. UPDATE PREVIEWS const previewsPath = getDataFilepath('previews', graphqlVersion) // GraphQL preview data structure - complex nested object from YAML // Using any because processPreviews is an external utility without type definitions const safeForPublicPreviews = yaml.load( await getRemoteRawContent(previewsPath, graphqlVersion), ) as any const previewsJson = processPreviews(safeForPublicPreviews) await updateStaticFile( previewsJson, path.join(graphqlStaticDir, graphqlVersion, 'previews.json'), ) // 2. UPDATE UPCOMING CHANGES const upcomingChangesPath = getDataFilepath('upcomingChanges', graphqlVersion) // GraphQL upcoming changes data - contains upcoming_changes array const previousUpcomingChanges = yaml.load(await fs.readFile(upcomingChangesPath, 'utf8')) as { upcoming_changes: unknown[] } const safeForPublicChanges = await getRemoteRawContent(upcomingChangesPath, graphqlVersion) await updateFile(upcomingChangesPath, safeForPublicChanges) const upcomingChangesJson = await processUpcomingChanges(safeForPublicChanges) await updateStaticFile( upcomingChangesJson, path.join(graphqlStaticDir, graphqlVersion, 'upcoming-changes.json'), ) // 3. UPDATE SCHEMAS // note: schemas live in separate files per version const previewFilePath = getDataFilepath('schemas', graphqlVersion) const previousSchemaString = await fs.readFile(previewFilePath, 'utf8') const latestSchema = await getRemoteRawContent(previewFilePath, graphqlVersion) await updateFile(previewFilePath, latestSchema) // Using any because processSchemas returns complex GraphQL schema structures const schemaJsonPerVersion = await processSchemas(latestSchema, safeForPublicPreviews) // This is slow! await updateStaticFile( schemaJsonPerVersion as any, path.join(graphqlStaticDir, graphqlVersion, 'schema.json'), ) // 4. UPDATE CHANGELOG if (allVersions[version].nonEnterpriseDefault) { // The changelog is only built for free-pro-team@latest const changelogEntry = await createChangelogEntry( previousSchemaString, latestSchema, safeForPublicPreviews, previousUpcomingChanges.upcoming_changes as any, (yaml.load(safeForPublicChanges) as { upcoming_changes: unknown[] }) .upcoming_changes as any, ) if (changelogEntry) { prependDatedEntry( changelogEntry, path.join(graphqlStaticDir, graphqlVersion, 'changelog.json'), ) } // Capture ignored changes for potential workflow notifications const ignoredSummary = getIgnoredChangesSummary() if (ignoredSummary) { allIgnoredChanges.push({ version: graphqlVersion, ...ignoredSummary, }) } } } // Ensure the YAML linter runs before checkinging in files execSync('npx prettier -w "**/*.{yml,yaml}"') // Output ignored changes for GitHub Actions if (allIgnoredChanges.length > 0) { const totalIgnored = allIgnoredChanges.reduce((sum, item) => sum + item.totalCount, 0) const uniqueTypes = [ ...new Set(allIgnoredChanges.flatMap((item) => item.types.map((t) => t.type))), ] console.log( '::notice title=GraphQL Ignored Changes::Found ignored change types that may need review', ) // Write outputs to GitHub Actions output file if (process.env.GITHUB_OUTPUT) { appendFileSync( process.env.GITHUB_OUTPUT, `ignored-changes=${JSON.stringify(allIgnoredChanges)}\n`, ) appendFileSync(process.env.GITHUB_OUTPUT, `ignored-count=${totalIgnored}\n`) appendFileSync(process.env.GITHUB_OUTPUT, `ignored-types=${uniqueTypes.join(', ')}\n`) } } } // get latest from github/github async function getRemoteRawContent(filepath: string, graphqlVersion: string) { const options: GitHubRepoOptions = { owner: 'github', repo: 'github', } // find the relevant branch in github/github and set it as options.ref let t0 = new Date().getTime() options.ref = await getBranchAsRef(options, graphqlVersion) let took = new Date().getTime() - t0 console.log(`Got ref (${options.ref}) for '${graphqlVersion}'. Took ${formatTime(took)}`) // add the filepath to the options so we can get the contents of the file options.path = `config/${path.basename(filepath)}` t0 = new Date().getTime() const contents = await getContents(options.owner, options.repo, options.ref, options.path) took = new Date().getTime() - t0 console.log(`Got content for '${options.path}' (in ${options.ref}). Took ${formatTime(took)}`) return contents } // find the relevant filepath in src/graphql/scripts/util/data-filenames.json function getDataFilepath(id: string, graphqlVersion: string) { const versionType = getVersionName(graphqlVersion) // for example, dataFilenames['schema']['ghes'] = schema.docs-enterprise.graphql const filename = dataFilenames[id][versionType] return path.join(graphqlStaticDir, graphqlVersion, filename) } async function getBranchAsRef( options: GitHubRepoOptions, graphqlVersion: string, branch: string | boolean = false, ): Promise { const versionType = getVersionName(graphqlVersion) as 'fpt' | 'ghec' | 'ghes' const defaultBranch = 'master' const branches: Record = { fpt: defaultBranch, ghec: defaultBranch, ghes: `enterprise-${graphqlVersion.replace('ghes-', '')}-release`, } // the first time this runs, it uses the branch found for the version above if (!branch) branch = branches[versionType] // set the branch as the ref const ref = `heads/${branch}` // check whether the branch can be found in github/github const exists = await hasMatchingRef(options.owner, options.repo, ref) // if ref is not found, the branch cannot be found, so try a fallback if (!exists) { const fallbackBranch = defaultBranch return await getBranchAsRef(options, graphqlVersion, fallbackBranch) } return ref } // given a GraphQL version like `ghes-2.22`, return `ghes`; // given a GraphQL version like `dotcom`, return as is function getVersionName(graphqlVersion: string) { return graphqlVersion.split('-')[0] } async function updateFile(filepath: string, content: string) { console.log(`Updating file ${filepath}`) await mkdirp(path.dirname(filepath)) return fs.writeFile(filepath, content, 'utf8') } // JSON data from GraphQL schema processing - complex nested structures // Using any because the structure varies (arrays, objects, nested schemas, etc.) async function updateStaticFile(json: any, filepath: string) { console.log(`Updating static file ${filepath}`) const jsonString = JSON.stringify(json, null, 2) return updateFile(filepath, jsonString) } function formatTime(ms: number) { if (ms < 1000) { return `${ms.toFixed(0)}ms` } const seconds = ms / 1000 if (seconds > 60) { return `${Math.round(seconds / 60)}m${Math.round(seconds % 60)}s` } return `${seconds.toFixed(1)}s` }