diff --git a/codegen.ts b/codegen.ts index 1280b80b39..650679d02e 100644 --- a/codegen.ts +++ b/codegen.ts @@ -3,35 +3,35 @@ import path from "path"; export const getConfig = ({ generatedFileName, - schema, + silent, }: { - schema: string; generatedFileName: string; -}): CodegenConfig => ({ - schema, +} & Pick): CodegenConfig => ({ documents: ["./src/**/*.ts", "./src/**/*.graphql", "./src/**/*.gql"].map( (d) => path.resolve(__dirname, d) ), - hooks: { - afterAllFileWrite: [ - `${path.resolve(__dirname, "./node_modules/.bin/prettier")} --write`, - ], - }, - overwrite: true, generates: { [generatedFileName]: { - plugins: ["typescript", "typescript-operations"], config: { - preResolveTypes: true, arrayInputCoercion: false, + preResolveTypes: true, scalars: { + Duration: "number", StringMap: "{ [key: string]: any }", Time: "Date", - Duration: "number", }, }, + plugins: ["typescript", "typescript-operations"], }, }, + hooks: { + afterAllFileWrite: [ + `${path.resolve(__dirname, "./node_modules/.bin/prettier")} --write`, + ], + }, + overwrite: true, + schema: "sdlschema/**/*.graphql", + silent, }); export const generatedFileName = path.resolve( @@ -40,6 +40,5 @@ export const generatedFileName = path.resolve( ); export default getConfig({ - schema: "sdlschema/**/*.graphql", generatedFileName, }); diff --git a/lint-staged.config.js b/lint-staged.config.js index 43a6bf8cb8..c6d4d071ae 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -7,5 +7,5 @@ module.exports = { "yarn prettier --parser graphql", ], // For GraphQL files, run eslint and prettier "*.{ts,tsx}": () => "tsc -p tsconfig.json --noEmit", // For TypeScript files, run tsc - "*": () => "yarn diff-schema", + "*": () => "yarn check-schema-and-codegen", }; diff --git a/package.json b/package.json index 7d9a8ad062..58ea007dfa 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "deploy:prod": "env-cmd -e production ts-node scripts/deploy/run-deploy", "deploy:do-not-use": ". ./scripts/deploy/deploy.sh", "dev": "env-cmd -e devLocal -r .env-cmdrc.local.json yarn start", - "diff-schema": "ts-node scripts/diff-local-schema-with-remote/index.ts", + "check-schema-and-codegen": "ts-node scripts/check-schema-and-codegen/script.ts", "eslint:fix": "yarn eslint:strict --fix", "eslint:staged": "STRICT=1 eslint", "eslint:strict": "STRICT=1 eslint '*.{js,ts,tsx}' 'src/**/*.ts?(x)' 'scripts/**/*.js' 'cypress/**/*.ts' 'src/gql/**/*.graphql'", diff --git a/scripts/check-schema-and-codegen/index.test.ts b/scripts/check-schema-and-codegen/index.test.ts new file mode 100644 index 0000000000..d5b692bb1f --- /dev/null +++ b/scripts/check-schema-and-codegen/index.test.ts @@ -0,0 +1,70 @@ +import fs from "fs"; +import { checkSchemaAndCodegenCore } from "."; +import { checkIsAncestor, getLatestCommitFromRemote } from "./utils"; + +jest.mock("fs", () => ({ + readFileSync: jest.fn().mockReturnValue(Buffer.from("file-contents")), +})); +jest.mock("path", () => ({ + resolve: jest.fn().mockReturnValue("{path.resolve()}"), +})); +jest.mock("./utils.ts", () => ({ + canResolveDNS: jest.fn(), + getLatestCommitFromRemote: jest.fn(), + checkIsAncestor: jest.fn(), + generateTypes: jest.fn(), +})); + +describe("checkSchemaAndCodegen", () => { + let consoleErrorSpy; + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + (checkIsAncestor as jest.Mock).mockResolvedValue(true); + (getLatestCommitFromRemote as jest.Mock).mockResolvedValue( + "{getLatestCommitFromRemote()}" + ); + }); + + it("returns 0 when offline", async () => { + (getLatestCommitFromRemote as jest.Mock).mockRejectedValueOnce( + new Error("TypeError: fetch failed") + ); + await expect(checkSchemaAndCodegenCore()).resolves.toBe(0); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "An error occured during GQL types validation: Error: TypeError: fetch failed" + ); + }); + + it("returns 1 when checkIsAncestor is false and the files are the same", async () => { + (checkIsAncestor as jest.Mock).mockResolvedValue(false); + await expect(checkSchemaAndCodegenCore()).resolves.toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "GQL types validation failed: Your local Evergreen code is missing commit {getLatestCommitFromRemote()}. Pull Evergreen and run 'yarn codegen'." + ); + }); + + it("returns 1 when checkIsAncestor is false and the files are different", async () => { + (checkIsAncestor as jest.Mock).mockResolvedValue(false); + (fs.readFileSync as jest.Mock) + .mockReturnValueOnce(Buffer.from("content1")) + .mockReturnValueOnce(Buffer.from("content2")); + await expect(checkSchemaAndCodegenCore()).resolves.toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "GQL types validation failed: Your local Evergreen code is missing commit {getLatestCommitFromRemote()}. Pull Evergreen and run 'yarn codegen'." + ); + }); + + it("returns 0 when checkIsAncestor is true and the files are the same", async () => { + await expect(checkSchemaAndCodegenCore()).resolves.toBe(0); + }); + + it("returns 1 when checkIsAncestor returns true and the files are different", async () => { + (fs.readFileSync as jest.Mock) + .mockReturnValueOnce(Buffer.from("content1")) + .mockReturnValueOnce(Buffer.from("content2")); + await expect(checkSchemaAndCodegenCore()).resolves.toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "GQL types validation failed: Your GQL types file ({path.resolve()}) is outdated. Run 'yarn codegen'." + ); + }); +}); diff --git a/scripts/check-schema-and-codegen/index.ts b/scripts/check-schema-and-codegen/index.ts new file mode 100644 index 0000000000..11ca1d3bfa --- /dev/null +++ b/scripts/check-schema-and-codegen/index.ts @@ -0,0 +1,45 @@ +import fs from "fs"; +import process from "process"; +import { generatedFileName as existingTypesFileName } from "../../codegen"; +import { + checkIsAncestor, + generateTypes, + getLatestCommitFromRemote, +} from "./utils"; + +const failCopy = "GQL types validation failed:"; + +/** + * An async function that returns 1 if the local types file is outdated and 0 otherwise. + * @returns Promise + */ +export const checkSchemaAndCodegenCore = async (): Promise => { + try { + const commit = await getLatestCommitFromRemote(); + const hasLatestCommit = await checkIsAncestor(commit); + if (!hasLatestCommit) { + console.error( + `${failCopy} Your local Evergreen code is missing commit ${commit}. Pull Evergreen and run 'yarn codegen'.` + ); + return 1; + } + // Finally check to see if 'yarn codegen' was ran. + const filenames = [await generateTypes(), existingTypesFileName]; + const [file1, file2] = filenames.map((filename) => + fs.readFileSync(filename) + ); + if (!file1.equals(file2)) { + console.error( + `${failCopy} Your GQL types file (${existingTypesFileName}) is outdated. Run 'yarn codegen'.` + ); + return 1; + } + } catch (error) { + console.error(`An error occured during GQL types validation: ${error}`); + } + return 0; +}; + +export const checkSchemaAndCodegen = async () => { + process.exit(await checkSchemaAndCodegenCore()); +}; diff --git a/scripts/check-schema-and-codegen/script.ts b/scripts/check-schema-and-codegen/script.ts new file mode 100644 index 0000000000..5dc20421f7 --- /dev/null +++ b/scripts/check-schema-and-codegen/script.ts @@ -0,0 +1,3 @@ +import { checkSchemaAndCodegen } from "."; + +checkSchemaAndCodegen(); diff --git a/scripts/check-schema-and-codegen/utils.ts b/scripts/check-schema-and-codegen/utils.ts new file mode 100644 index 0000000000..b23ffb984d --- /dev/null +++ b/scripts/check-schema-and-codegen/utils.ts @@ -0,0 +1,73 @@ +import dns from "dns"; +import fs from "fs"; +import os from "os"; +import { generate } from "@graphql-codegen/cli"; +import { execSync } from "child_process"; +import process from "process"; +import { getConfig } from "../../codegen"; + +const GITHUB_API = "https://api.github.com"; +const GQL_DIR = "graphql/schema"; +const LOCAL_SCHEMA = "sdlschema"; +const REPO = "/repos/evergreen-ci/evergreen"; + +/** + * Get the latest commit that was made to the GQL folder of the remote Evergreen repository. + * @returns A Promise that resolves to the SHA of the latest commit. + * @throws {Error} When failed to fetch commits. + */ +export const getLatestCommitFromRemote = async (): Promise => { + const url = `${GITHUB_API}${REPO}/commits?path=${GQL_DIR}&sha=main`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}. Status: ${response.status}`); + } + + const commits = await response.json(); + + if (commits.length > 0) { + return commits[0].sha; + } + throw new Error(`No commits found for this path: ${url}`); +}; + +/** + * Check if the local Evergreen repo contains a given commit. + * @param commit The commit string that will be checked to see if it exists in the Evergreen repo. + * @returns A Promise that resolves to true if local repo contains the given commit, and false otherwise. + * @throws {Error} When an error occurs while executing the command. + */ +export const checkIsAncestor = async (commit: string): Promise => { + const localSchemaSymlink = fs.readlinkSync(LOCAL_SCHEMA); + const originalDir = process.cwd(); + try { + process.chdir(localSchemaSymlink); + execSync(`git merge-base --is-ancestor ${commit} HEAD`); + process.chdir(originalDir); + return true; + } catch (error) { + process.chdir(originalDir); + // Error status 1 and 128 means that the commit is not an anecestor and the user must fetch. + // Error code docs: https://www.git-scm.com/docs/api-error-handling/ + if (error.status === 1 || error.status === 128) { + return false; + } + throw new Error(`Error checking ancestor: ${error.message}`); + } +}; + +/** + * Generate types based on sdlschema. + * @returns A Promise that resolves to the path of the generated file. + */ +export const generateTypes = async (): Promise => { + const generatedFileName = `${os.tmpdir()}/types.ts`; + await generate( + getConfig({ + generatedFileName, + silent: true, + }), + true + ); + return generatedFileName; +}; diff --git a/scripts/diff-local-schema-with-remote/diffTypes.test.ts b/scripts/diff-local-schema-with-remote/diffTypes.test.ts deleted file mode 100644 index 3511b64a46..0000000000 --- a/scripts/diff-local-schema-with-remote/diffTypes.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { diffTypes } from "./diffTypes"; -import fs from "fs"; -import process from "process"; -import { canResolveDNS, checkIsAncestor } from "./utils"; - -jest.mock("fs", () => ({ - existsSync: jest.fn().mockReturnValue(true), - readFileSync: jest.fn().mockReturnValue(Buffer.from("file-contents")), -})); -jest.mock("path"); -jest.mock("./utils.ts", () => ({ - canResolveDNS: jest.fn().mockResolvedValue(true), - getRemoteLatestCommitSha: jest.fn().mockResolvedValue("mocked-sha"), - checkIsAncestor: jest.fn().mockResolvedValue(true), - downloadAndSaveFile: jest.fn().mockResolvedValue(undefined), - fetchFiles: jest.fn().mockResolvedValue(undefined), - downloadAndGenerate: jest.fn().mockResolvedValue("mocked-path/types.ts"), -})); - -describe("diffTypes", () => { - let exitSpy; - let consoleInfoSpy; - let consoleErrorSpy; - beforeEach(() => { - exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); - consoleInfoSpy = jest.spyOn(console, "info").mockImplementation(() => {}); - consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it("exit with 0 when internet is unavailable", async () => { - (canResolveDNS as jest.Mock).mockResolvedValue(false); - await diffTypes(); - expect(exitSpy).toHaveBeenCalledWith(0); - expect(consoleInfoSpy).toHaveBeenCalledWith( - "Skipping GQL codegen validation because I can't connect to github.com." - ); - }); - - it("exit with 1 when one of the generated types files does not exist", async () => { - (fs.existsSync as jest.Mock) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); - await diffTypes(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "Types file located at undefined does not exist. Validation failed." - ); - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - it("exit with 1 when types files are different and checkIsAncestor is false", async () => { - (fs.readFileSync as jest.Mock) - .mockReturnValueOnce(Buffer.from("content1")) - .mockReturnValueOnce(Buffer.from("content2")); - (checkIsAncestor as jest.Mock).mockResolvedValue(false); - await diffTypes(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "You are developing against an outdated schema and the codegen task will fail in CI. Run 'yarn codegen' against the latest Evergreen code." - ); - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - it("exit with 0 when types files are different and checkIsAncestor is true", async () => { - (fs.readFileSync as jest.Mock) - .mockReturnValueOnce(Buffer.from("content1")) - .mockReturnValueOnce(Buffer.from("content2")); - (checkIsAncestor as jest.Mock).mockResolvedValue(true); - await diffTypes(); - expect(exitSpy).toHaveBeenCalledWith(0); - }); - - it("exit with 1 when types files are the same and checkIsAncestor is false", async () => { - (checkIsAncestor as jest.Mock).mockResolvedValue(false); - await diffTypes(); - expect(exitSpy).toHaveBeenCalledWith(0); - }); - - it("exit with 1 when types files are the same and checkIsAncestor is true", async () => { - await diffTypes(); - expect(exitSpy).toHaveBeenCalledWith(0); - }); - - it("handle error and exit with 1", async () => { - (canResolveDNS as jest.Mock).mockRejectedValue(new Error("Test Error")); - await diffTypes(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "An issue occurred validating the generated GQL types file: Error: Test Error" - ); - expect(exitSpy).toHaveBeenCalledWith(1); - }); -}); diff --git a/scripts/diff-local-schema-with-remote/diffTypes.ts b/scripts/diff-local-schema-with-remote/diffTypes.ts deleted file mode 100644 index 2c7e79e436..0000000000 --- a/scripts/diff-local-schema-with-remote/diffTypes.ts +++ /dev/null @@ -1,49 +0,0 @@ -import fs from "fs"; -import { generatedFileName as localGeneratedTypesFileName } from "../../codegen"; -import process from "process"; -import { canResolveDNS, checkIsAncestor, downloadAndGenerate } from "./utils"; - -/** - * Compare the local generated types with the remote version. - * Exit with code 1 if the local schema is outdated or validation fails and 0 otherwise. - * @returns {Promise} - */ -export const diffTypes = async (): Promise => { - try { - const hasInternetAccess = await canResolveDNS("github.com"); - if (!hasInternetAccess) { - console.info( - "Skipping GQL codegen validation because I can't connect to github.com." - ); - process.exit(0); - } - const latestGeneratedTypesFileName = await downloadAndGenerate(); - const filenames = [ - latestGeneratedTypesFileName, - localGeneratedTypesFileName, - ]; - filenames.forEach((filename) => { - if (!fs.existsSync(filename)) { - console.error( - `Types file located at ${filename} does not exist. Validation failed.` - ); - process.exit(1); - } - }); - const [file1, file2] = filenames.map((filename) => - fs.readFileSync(filename) - ); - if (!file1.equals(file2) && !(await checkIsAncestor())) { - console.error( - "You are developing against an outdated schema and the codegen task will fail in CI. Run 'yarn codegen' against the latest Evergreen code." - ); - process.exit(1); - } - process.exit(0); - } catch (error) { - console.error( - `An issue occurred validating the generated GQL types file: ${error}` - ); - process.exit(1); - } -}; diff --git a/scripts/diff-local-schema-with-remote/index.ts b/scripts/diff-local-schema-with-remote/index.ts deleted file mode 100644 index a4ba018467..0000000000 --- a/scripts/diff-local-schema-with-remote/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { diffTypes } from "./diffTypes"; - -diffTypes(); diff --git a/scripts/diff-local-schema-with-remote/utils.ts b/scripts/diff-local-schema-with-remote/utils.ts deleted file mode 100644 index 96839ee570..0000000000 --- a/scripts/diff-local-schema-with-remote/utils.ts +++ /dev/null @@ -1,163 +0,0 @@ -import dns from "dns"; -import fs from "fs"; -import os from "os"; -import path from "path"; -import { generate } from "@graphql-codegen/cli"; -import { getConfig } from "../../codegen"; -import { execSync } from "child_process"; -import process from "process"; - -const GITHUB_API = "https://api.github.com"; -const GQL_DIR = "graphql/schema"; -const LOCAL_SCHEMA = "sdlschema"; -const REPO = "/repos/evergreen-ci/evergreen"; -const REPO_CONTENTS = `${REPO}/contents/`; -const USER_AGENT = "Mozilla/5.0"; - -/** - * Checks if a given domain can be resolved. - * - * @async - * @function - * @param {string} domain - The domain name to check. - * @returns {Promise} - Resolves to `true` if the domain can be resolved, `false` otherwise. - */ -export const canResolveDNS = (domain: string) => - new Promise((resolve) => { - dns.lookup(domain, (err) => { - if (err) { - resolve(false); - } else { - resolve(true); - } - }); - }); - -/** - * Get the latest commit that was made to the GQL folder. - * @returns {Promise} A Promise that resolves to the SHA of the latest commit. - * @throws {Error} When failed to fetch commits. - */ -export async function getRemoteLatestCommitSha(): Promise { - const url = `${GITHUB_API}${REPO}/commits?path=${GQL_DIR}&sha=main`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch ${url}. Status: ${response.status}`); - } - - const commits = await response.json(); - - if (commits.length > 0) { - return commits[0].sha; - } else { - throw new Error(`No commits found for this path: ${url}`); - } -} - -/** - * Check if the local Evergreen repo contains the latest commit made to the GQL folder. - * @returns {Promise} A Promise that resolves to true if local repo contains the latest commit, and false otherwise. - * @throws {Error} When an error occurs while executing the command. - */ -export const checkIsAncestor = async (): Promise => { - const remoteSha = await getRemoteLatestCommitSha(); - const localSchemaSymlink = fs.readlinkSync(LOCAL_SCHEMA); - console.log(localSchemaSymlink); - const originalDir = process.cwd(); - try { - process.chdir(localSchemaSymlink); - execSync(`git merge-base --is-ancestor ${remoteSha} HEAD`); - process.chdir(originalDir); - return true; - } catch (error) { - process.chdir(originalDir); - if (error.status === 1) { - return false; - } - throw new Error(`Error executing command: ${error.message}`); - } -}; - -/** - * Download the file at the given url and save it to the given savePath. - * @param {string} url - The URL of the file to be downloaded. - * @param {string} savePath - The local path where the file should be saved. - * @returns {Promise} - * @throws {Error} When failed to fetch the file. - */ -export const downloadAndSaveFile = async ( - url: string, - savePath: string -): Promise => { - const response = await fetch(url, { - headers: { - "User-Agent": USER_AGENT, - }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch ${url}. Status: ${response.status}`); - } - const data = await response.arrayBuffer(); - fs.writeFileSync(savePath, Buffer.from(data)); -}; - -/** - * Recursively fetch and save the files at the given github repoPath to the given localPath. - * @param {string} repoPath - The path in the GitHub repository. - * @param {string} localPath - The local path where the files should be saved. - * @returns {Promise} - * @throws {Error} When failed to fetch the files. - */ -export const fetchFiles = async ( - repoPath: string, - localPath: string -): Promise => { - const response = await fetch(`${GITHUB_API}${repoPath}`, { - headers: { - "User-Agent": USER_AGENT, - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch ${GITHUB_API}${repoPath}. Status: ${response.status}` - ); - } - - const files = await response.json(); - const promises = files.map((file) => { - const fileSavePath = path.join(localPath, file.name); - if (file.type === "file") { - return downloadAndSaveFile(file.download_url, fileSavePath); - } else if (file.type === "dir") { - if (!fs.existsSync(fileSavePath)) { - fs.mkdirSync(fileSavePath, { recursive: true }); - } - return fetchFiles(`${REPO_CONTENTS}${file.path}`, fileSavePath); - } - }); - - await Promise.all(promises); -}; - -/** - * Download GQL files from remote and generate types. - * @returns {Promise} A Promise that resolves to the path of the generated file. - */ -export const downloadAndGenerate = async (): Promise => { - const tempDir = os.tmpdir(); - fs.mkdirSync(tempDir, { recursive: true }); - await fetchFiles( - path.join(REPO_CONTENTS, GQL_DIR), - path.join(tempDir, GQL_DIR) - ); - const latestGeneratedTypesFileName = `${tempDir}/types.ts`; - await generate( - getConfig({ - schema: `${tempDir}/${GQL_DIR}/**/*.graphql`, - generatedFileName: latestGeneratedTypesFileName, - }), - true - ); - return latestGeneratedTypesFileName; -};