const { diff, ChangeType } = require('@graphql-inspector/core') const { loadSchema } = require('@graphql-tools/load') const git = require('../../lib/git-utils') const fs = require('fs') const yaml = require('js-yaml') // check for required PAT if (!process.env.GITHUB_TOKEN) { console.error('Error! You must have a GITHUB_TOKEN set in an .env file to run this script.') process.exit(1) } // main() async function main() { // Load the previous schema from this repo // TODO -- how to make sure that this script runs _before_ this artifact is updated? // Maybe hook into the existing `update-files` script instead of being a stand-alone script. const oldSchemaString = fs.readFileSync('data/graphql/schema.docs.graphql').toString() // Load the latest schema from github/github const tree = await git.getTree('github', 'github', 'heads/master') const schemaFileBlob = tree.find(entry => entry.path.includes('config/schema.docs.graphql') && entry.type === 'blob') const newSchemaBuffer = await git.getContentsForBlob('github', 'github', schemaFileBlob) const previewsString = fs.readFileSync('data/graphql/graphql_previews.yml') const previews = yaml.safeLoad(previewsString) // TODO how to make sure to get these before the file is updated? const oldUpcomingChangesString = fs.readFileSync('data/graphql/graphql_upcoming_changes_public.yml') const oldUpcomingChanges = yaml.safeLoad(oldUpcomingChangesString).upcoming_changes // TODO actually get different changes here const newUpcomingChanges = oldUpcomingChanges const changelogEntry = createChangelogEntry(oldSchemaString, newSchemaBuffer.toString(), previews, oldUpcomingChanges, newUpcomingChanges) if (changelogEntry) { prependDatedEntry(changelogEntry, 'lib/graphql/static/changelog.json') } } /** * 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.) * @param {object} changelogEntry * @param {string} targetPath * @return {void} */ function prependDatedEntry(changelogEntry, targetPath) { // Build a `yyyy-mm-dd`-formatted date string // and tag the changelog entry with it const today = new Date() const todayString = String(today.getFullYear()) + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0') changelogEntry.date = todayString const previousChangelogString = fs.readFileSync(targetPath) const previousChangelog = JSON.parse(previousChangelogString) // 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. * @param {string} [oldSchemaString] * @param {string} [newSchemaString] * @param {Array} [previews] * @param {Array} [oldUpcomingChanges] * @param {Array} [newUpcomingChanges] * @return {object?} */ async function createChangelogEntry(oldSchemaString, newSchemaString, previews, oldUpcomingChanges, newUpcomingChanges) { // Create schema objects out of the strings const oldSchema = await loadSchema(oldSchemaString) const newSchema = await loadSchema(newSchemaString) // Generate changes between the two schemas const changes = diff(oldSchema, newSchema) const changesToReport = [] changes.forEach(function (change) { if (CHANGES_TO_REPORT.includes(change.type)) { changesToReport.push(change) } else if (CHANGES_TO_IGNORE.includes(change.type)) { // Do nothing } else { throw "This change type should be added to CHANGES_TO_REPORT or CHANGES_TO_IGNORE: " + change.type } }) const { schemaChangesToReport, previewChangesToReport } = segmentPreviewChanges(changesToReport, previews) const addedUpcomingChanges = newUpcomingChanges.filter(function (change) { // 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 || previewChangesToReport.length > 0 || addedUpcomingChanges.length > 0) { const changelogEntry = { schemaChanges: [], previewChanges: [], upcomingChanges: [], } const schemaChange = { title: 'The GraphQL schema includes these changes:', // Replace single quotes which wrap field/argument/type names with backticks changes: cleanMessagesFromChanges(schemaChangesToReport) } changelogEntry.schemaChanges.push(schemaChange) for (const previewTitle in previewChangesToReport) { let previewChanges = previewChangesToReport[previewTitle] let cleanTitle = cleanPreviewTitle(previewTitle) let entryTitle = "The [" + cleanTitle + "](/graphql/overview/schema-previews#" + previewAnchor(cleanTitle) + ") includes these changes:" changelogEntry.previewChanges.push({ title: entryTitle, changes: cleanMessagesFromChanges(previewChanges.changes), }) } if (addedUpcomingChanges.length > 0) { changelogEntry.upcomingChanges.push({ title: "The following changes will be made to the schema:", changes: addedUpcomingChanges.map(function (change) { const location = change.location const description = change.description const date = change.date.split("T")[0] return "On member `" + location + "`:" + description + " **Effective " + date + "**." }) }) } return changelogEntry } else { return null } } /** * Prepare the preview title from github/github source for the docs. * (ported from build-changelog-from-markdown) * @param {string} title * @return {string} */ function cleanPreviewTitle(title) { 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 https://github.com/github/graphql-docs/blob/master/lib/graphql_docs/update_internal_developer/change_log.rb#L281) * @param {string} [previewTitle] * @return {string} */ function previewAnchor(previewTitle) { return previewTitle .toLowerCase() .replace(/ /g, '-') .replace(/[^\w-]/g, '') } /** * Turn changes from graphql-inspector into messages for the HTML changelog. * @param {Array} changes * @return {Array} */ function cleanMessagesFromChanges(changes) { return changes.map(function (change) { // 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 https://github.com/github/graphql-docs/blob/7e6a5ccbf13cc7d875fee65527b25bc49e886b41/lib/graphql_docs/update_internal_developer/change_log.rb#L230) * @param {Array} changesToReport * @param {object} previews * @return {object} */ function segmentPreviewChanges(changesToReport, previews) { // Build a map of `{ path => previewTitle` } // for easier lookup of change to preview const pathToPreview = {} previews.forEach(function (preview) { preview.toggled_on.forEach(function (path) { pathToPreview[path] = preview.title }) }) const schemaChanges = [] const changesByPreview = {} changesToReport.forEach(function (change) { // 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 = null let previewTitle = null let previewChanges = 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 } } 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, ] const CHANGES_TO_IGNORE = [ ChangeType.FieldArgumentDescriptionChanged, ChangeType.DirectiveRemoved, ChangeType.DirectiveAdded, ChangeType.DirectiveDescriptionChanged, ChangeType.DirectiveLocationAdded, ChangeType.DirectiveLocationRemoved, ChangeType.DirectiveArgumentAdded, ChangeType.DirectiveArgumentRemoved, ChangeType.DirectiveArgumentDescriptionChanged, ChangeType.DirectiveArgumentDefaultValueChanged, ChangeType.DirectiveArgumentTypeChanged, ChangeType.EnumValueDescriptionChanged, ChangeType.EnumValueDeprecationReasonChanged, ChangeType.EnumValueDeprecationReasonAdded, ChangeType.EnumValueDeprecationReasonRemoved, ChangeType.FieldDescriptionChanged, ChangeType.FieldDescriptionAdded, ChangeType.FieldDescriptionRemoved, ChangeType.FieldDeprecationAdded, ChangeType.FieldDeprecationRemoved, ChangeType.FieldDeprecationReasonChanged, ChangeType.FieldDeprecationReasonAdded, ChangeType.FieldDeprecationReasonRemoved, ChangeType.InputFieldDescriptionAdded, ChangeType.InputFieldDescriptionRemoved, ChangeType.InputFieldDescriptionChanged, ChangeType.TypeDescriptionChanged, ChangeType.TypeDescriptionRemoved, ChangeType.TypeDescriptionAdded, ] module.exports = { createChangelogEntry, cleanPreviewTitle, previewAnchor, prependDatedEntry }