import { diff, ChangeType, Change } from '@graphql-inspector/core' import { loadSchema } from '@graphql-tools/load' import fs from 'fs' import { renderContent } from '@/content-render/index' interface UpcomingChange { location: string date: string description: string } interface Preview { title: string toggled_on: string[] } interface ChangelogSchemaChange { title: string changes: string[] } interface ChangelogPreviewChange { title: string changes: string[] } interface ChangelogUpcomingChange { title: string changes: string[] } export interface ChangelogEntry { date?: string schemaChanges: ChangelogSchemaChange[] previewChanges: ChangelogPreviewChange[] upcomingChanges: ChangelogUpcomingChange[] } interface PreviewChanges { title: string changes: Change[] } interface SegmentedChanges { schemaChangesToReport: Change[] previewChangesToReport: Record } interface IgnoredChangeType { type: string count: number } interface IgnoredChangesSummary { totalCount: number typeCount: number types: IgnoredChangeType[] } /** * Tag `changelogEntry` with `date: YYYY-mm-dd`, then prepend it to the JSON * structure written to `targetPath`. (`changelogEntry` and that file are modified in place.) */ export function prependDatedEntry(changelogEntry: ChangelogEntry, targetPath: string): void { // Build a `yyyy-mm-dd`-formatted date string // and tag the changelog entry with it const todayString = new Date().toISOString().slice(0, 10) changelogEntry.date = todayString const previousChangelogString = fs.readFileSync(targetPath, 'utf8') const previousChangelog = JSON.parse(previousChangelogString) as ChangelogEntry[] // add a new entry to the changelog data previousChangelog.unshift(changelogEntry) // rewrite the updated changelog fs.writeFileSync(targetPath, JSON.stringify(previousChangelog, null, 2)) } /** * Compare `oldSchemaString` to `newSchemaString`, and if there are any * changes that warrant a changelog entry, return a changelog entry. * Based on the parsed `previews`, identify changes that are under a preview. * Otherwise, return null. */ export async function createChangelogEntry( oldSchemaString: string, newSchemaString: string, previews: Preview[], oldUpcomingChanges: UpcomingChange[], newUpcomingChanges: UpcomingChange[], ): Promise { // Create schema objects out of the strings // Using 'as any' because loadSchema accepts string schema directly without requiring loaders const oldSchema = await loadSchema(oldSchemaString, {} as any) const newSchema = await loadSchema(newSchemaString, {} as any) // Generate changes between the two schemas const changes = await diff(oldSchema, newSchema) const changesToReport: Change[] = [] const ignoredChanges: Change[] = [] for (const change of changes) { if (CHANGES_TO_REPORT.includes(change.type)) { changesToReport.push(change) } else { // Track ignored changes for visibility ignoredChanges.push(change) } } // Log warnings for ignored change types to provide visibility if (ignoredChanges.length > 0) { const ignoredTypes = [...new Set(ignoredChanges.map((change) => change.type))] console.warn( `⚠️ GraphQL changelog: Ignoring ${ignoredChanges.length} changes of ${ignoredTypes.length} type(s):`, ) for (const type of ignoredTypes) { const count = ignoredChanges.filter((change) => change.type === type).length console.warn(` - ${type} (${count} change${count > 1 ? 's' : ''})`) } console.warn( ' These change types are not in CHANGES_TO_REPORT and will not appear in the changelog.', ) } // Store ignored changes for potential workflow outputs ;(createChangelogEntry as any).lastIgnoredChanges = ignoredChanges const { schemaChangesToReport, previewChangesToReport } = segmentPreviewChanges( changesToReport, previews, ) const addedUpcomingChanges = newUpcomingChanges.filter(function (change): boolean { // Manually check each of `newUpcomingChanges` for an equivalent entry // in `oldUpcomingChanges`. return !oldUpcomingChanges.find(function (oldChange) { return ( oldChange.location === change.location && oldChange.date === change.date && oldChange.description === change.description ) }) }) // If there were any changes, create a changelog entry if ( schemaChangesToReport.length > 0 || Object.keys(previewChangesToReport).length > 0 || addedUpcomingChanges.length > 0 ) { const changelogEntry: ChangelogEntry = { schemaChanges: [], previewChanges: [], upcomingChanges: [], } const cleanedSchemaChanges = cleanMessagesFromChanges(schemaChangesToReport) const renderedScheamChanges = await Promise.all( cleanedSchemaChanges.map(async (change): Promise => { return await renderContent(change) }), ) const schemaChange: ChangelogSchemaChange = { title: 'The GraphQL schema includes these changes:', // Replace single quotes which wrap field/argument/type names with backticks changes: renderedScheamChanges, } changelogEntry.schemaChanges.push(schemaChange) for (const previewTitle in previewChangesToReport) { const previewChanges = previewChangesToReport[previewTitle] const cleanedPreviewChanges = cleanMessagesFromChanges(previewChanges.changes) const renderedPreviewChanges = await Promise.all( cleanedPreviewChanges.map(async (change): Promise => { return renderContent(change) }), ) const cleanTitle = cleanPreviewTitle(previewTitle) const entryTitle = `The [${cleanTitle}](/graphql/overview/schema-previews#${previewAnchor( cleanTitle, )}) includes these changes:` changelogEntry.previewChanges.push({ title: entryTitle, changes: renderedPreviewChanges, }) } if (addedUpcomingChanges.length > 0) { const cleanedUpcomingChanges = addedUpcomingChanges.map((change) => { const location = change.location const description = change.description const date = change.date.split('T')[0] return `On member \`${location}\`:${description} **Effective ${date}**.` }) const renderedUpcomingChanges = await Promise.all( cleanedUpcomingChanges.map(async (change): Promise => { return await renderContent(change) }), ) changelogEntry.upcomingChanges.push({ title: 'The following changes will be made to the schema:', changes: renderedUpcomingChanges, }) } return changelogEntry } else { return null } } /** * Prepare the preview title from github/github source for the docs. */ export function cleanPreviewTitle(title: string): string { if (title === 'UpdateRefsPreview') { title = 'Update refs preview' } else if (title === 'MergeInfoPreview') { title = 'Merge info preview' } else if (!title.endsWith('preview')) { title = `${title} preview` } return title } /** * Turn the given title into an HTML-ready anchor. * (ported from graphql-docs/lib/graphql_docs/update_internal_developer/change_log.rb#L281) */ export function previewAnchor(previewTitle: string): string { return previewTitle .toLowerCase() .replace(/ /g, '-') .replace(/[^\w-]/g, '') } /** * Turn changes from graphql-inspector into messages for the HTML changelog. */ export function cleanMessagesFromChanges(changes: Change[]): string[] { return changes.map(function (change): string { // replace single quotes around graphql names with backticks, // to match previous behavior from graphql-schema-comparator return change.message.replace(/'([a-zA-Z. :!]+)'/g, '`$1`') }) } /** * Split `changesToReport` into two parts, * one for changes in the main schema, * and another for changes that are under preview. * (Ported from /graphql-docs/lib/graphql_docs/update_internal_developer/change_log.rb#L230) */ export function segmentPreviewChanges( changesToReport: Change[], previews: Preview[], ): SegmentedChanges { // Build a map of `{ path => previewTitle` } // for easier lookup of change to preview const pathToPreview: Record = {} for (const preview of previews) { for (const path of preview.toggled_on) { pathToPreview[path] = preview.title } } const schemaChanges: Change[] = [] const changesByPreview: Record = {} for (const change of changesToReport) { // For each change, see if its path _or_ one of its ancestors // is covered by a preview. If it is, mark this change as belonging to a preview const pathParts = change.path?.split('.') || [] let testPath: string | null = null let previewTitle: string | null = null let previewChanges: PreviewChanges | null = null while (pathParts.length > 0 && !previewTitle) { testPath = pathParts.join('.') previewTitle = pathToPreview[testPath] // If that path didn't find a match, then we'll // check the next ancestor. pathParts.pop() } if (previewTitle) { previewChanges = changesByPreview[previewTitle] || (changesByPreview[previewTitle] = { title: previewTitle, changes: [], }) previewChanges.changes.push(change) } else { schemaChanges.push(change) } } return { schemaChangesToReport: schemaChanges, previewChangesToReport: changesByPreview } } // We only want to report changes to schema structure. // Deprecations are covered by "upcoming changes." // By listing the changes explicitly here, we can make sure that, // if the library changes, we don't miss publishing anything that we mean to. // This was originally ported from graphql-docs/lib/graphql_docs/update_internal_developer/change_log.rb#L35-L103 const CHANGES_TO_REPORT = [ ChangeType.FieldArgumentDefaultChanged, ChangeType.FieldArgumentTypeChanged, ChangeType.EnumValueRemoved, ChangeType.EnumValueAdded, ChangeType.FieldRemoved, ChangeType.FieldAdded, ChangeType.FieldTypeChanged, ChangeType.FieldArgumentAdded, ChangeType.FieldArgumentRemoved, ChangeType.ObjectTypeInterfaceAdded, ChangeType.ObjectTypeInterfaceRemoved, ChangeType.InputFieldRemoved, ChangeType.InputFieldAdded, ChangeType.InputFieldDefaultValueChanged, ChangeType.InputFieldTypeChanged, ChangeType.TypeRemoved, ChangeType.TypeAdded, ChangeType.TypeKindChanged, ChangeType.UnionMemberRemoved, ChangeType.UnionMemberAdded, ChangeType.SchemaQueryTypeChanged, ChangeType.SchemaMutationTypeChanged, ChangeType.SchemaSubscriptionTypeChanged, ChangeType.DirectiveUsageFieldDefinitionRemoved, ] // CHANGES_TO_IGNORE list removed - now we only process changes explicitly listed // in CHANGES_TO_REPORT and silently ignore all others for future compatibility /** * Get the ignored change types from the last changelog entry creation */ export function getLastIgnoredChanges(): Change[] { return (createChangelogEntry as any).lastIgnoredChanges || [] } /** * Get summary of ignored change types for workflow outputs */ export function getIgnoredChangesSummary(): IgnoredChangesSummary | null { const ignored = getLastIgnoredChanges() if (ignored.length === 0) return null const types = [...new Set(ignored.map((change) => change.type))] const summary: IgnoredChangesSummary = { totalCount: ignored.length, typeCount: types.length, types: types.map( (type): IgnoredChangeType => ({ type, count: ignored.filter((change) => change.type === type).length, }), ), } return summary } export default { createChangelogEntry, cleanPreviewTitle, previewAnchor, prependDatedEntry }