diff --git a/.github/helpers/create-npm-dep-report-issues.js b/.github/helpers/create-npm-dep-report-issues.js new file mode 100644 index 0000000000..caa9b6cf60 --- /dev/null +++ b/.github/helpers/create-npm-dep-report-issues.js @@ -0,0 +1,22 @@ +const path = require("path"); +const createAndCloseExistingIssue = require("./github-api/create-and-close-existing-issue"); +const outputText = require(path.resolve(__dirname, `../../outputText.json`)); + +/** + * THIS FILE DOES NOT REQUIRE ANY EDITING. + * Place within .github/helpers/ + */ + +// Get package.json paths from env. +const packageJsonPaths = JSON.parse(process.env.packageJsonPaths); + +(async () => { + // Create an array of promises for each packageJsonPath. + const promises = packageJsonPaths.map(async (packagePath) => { + // Await the completion of create and close existing issue. + await createAndCloseExistingIssue(packagePath, outputText[packagePath]); + }); + + // Wait for all issues to be created. + await Promise.all(promises); +})(); diff --git a/.github/helpers/create-npm-dep-report.js b/.github/helpers/create-npm-dep-report.js new file mode 100644 index 0000000000..5891661208 --- /dev/null +++ b/.github/helpers/create-npm-dep-report.js @@ -0,0 +1,261 @@ +const path = require("path"); +const outdatedDeps = require(path.resolve( + __dirname, + `../../outdatedDeps.json` +)); + +const LOCAL_TEST = false; +const TEST_PACKAGEJSON_PATHS = ["src/frontend", "src/backend"]; + +/** + * THIS FILE DOES NOT REQUIRE ANY EDITING. + * Place within .github/helpers/ + * + * To test this file locally, + * - Generate output from parse-npm-deps.js + * - Set LOCAL_TEST variable to true. + * - Edit TEST_PACKAGEJSON_PATHS if necessary. + * - From root, run "node .github/helpers/create-npm-dep-report > outputText.json" + * - Check the outputText.json file, then delete it. + */ + +// Get package.json paths from env. +const packageJsonPaths = LOCAL_TEST + ? TEST_PACKAGEJSON_PATHS + : JSON.parse(process.env.packageJsonPaths); + +// Save results to json. +let results = {}; + +// Emojis. +const check = "✔️"; +const attention = "⚠️"; + +// Badge color codes (checked for WCAG standards). +const red = "701807"; // White text. +const orange = "9e3302"; // White text. +const yellow = "f5c60c"; // Black text. +const green = "0B6018"; // White text. +const blue = "0859A1"; // White text. + +// GitHub Markdown Formatting. +const heading = (text, size) => `${"#".repeat(size)} ${text}\n`; +const codeBlock = (text, language) => `\`\`\`${language}\n${text}\n\`\`\`\n\n`; +const lineBreak = () => `\n
\n`; +const line = (text) => `${text}\n`; + +// Formatted date. +const getFormattedDate = () => { + const date = new Date(); + + // Get day of the month. + const day = date.getDate(); + + // Determine the ordinal suffix. + const ordinal = (day) => { + const s = ["th", "st", "nd", "rd"]; + const v = day % 100; + return day + (s[(v - 20) % 10] || s[v] || s[0]); + }; + + // Formatter for the rest of the date. + const formatter = new Intl.DateTimeFormat("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + + // Format the date and replace the day number with ordinal. + return formatter.format(date).replace(/\d+/, ordinal(day)); +}; + +// Messages. +const title = `NPM Dependency Report - ${getFormattedDate()}`; +const subTitle = + "Versions of npm packages have been checked against their latest versions from the npm registry."; +const upToDateMsg = "dependencies are all up-to-date."; +const outOfDateMsg = "dependencies are out-of-date."; + +// Calculate percentage of packages up to date. +const calculateUpToDatePercentage = (total, outdated) => { + if (total === 0) return 0; + + const upToDatePackages = total - outdated; + const percentage = (upToDatePackages / total) * 100; + return Math.round(percentage); +}; + +// Output a command to install all dependencies in array. +const outputMultiPackageInstallCmd = (dependencies, packagePath, isDevDep) => { + let installCmd = `npm install${isDevDep ? " -D" : ""} `; + installCmd += dependencies + .map((obj) => `${obj.dependency}@${obj.latestVersion}`) + .join(" "); + + results[packagePath] += `${codeBlock(installCmd, "")}\n`; +}; + +// Output Dependencies in an array. +const outputDepsByVersionChange = ( + dependencies, + versionChange, + packagePath, + isDevDep +) => { + const headerTag = isDevDep ? `${versionChange}_dev` : `${versionChange}`; + const badgeColor = + versionChange === "major" + ? orange + : versionChange === "minor" + ? blue + : green; + + // Output header. + results[packagePath] += `${line(`![${headerTag}]`)}\n\n`; + + // Output start of spoiler. + results[packagePath] += `${line(`
`)}\n`; + results[packagePath] += `${line(``)}`; + results[packagePath] += `${line( + `Expand to see individual installs.

\n` + )}`; + + // Output a command to install all dependencies in array. + outputMultiPackageInstallCmd(dependencies, packagePath, isDevDep); + + // Output end of spoiler summary. + results[packagePath] += `${line(`
\n`)}`; + + // List dependency updates. + for (const key in dependencies) { + const { dependency, version, latestVersion } = dependencies[key]; + + results[packagePath] += `${line( + `- [ ] \`${dependency}\` Update from version \`${version}\` to \`${latestVersion}\` by running...` + )}`; + results[packagePath] += `${codeBlock( + `npm install${isDevDep ? " -D" : ""} ${dependency}@${latestVersion}`, + "" + )}`; + } + + // Output end of spoiler. + results[packagePath] += `${line(`
\n`)}`; + + // Add Header text + results[packagePath] += `${line( + `[${headerTag}]: https://img.shields.io/badge/${versionChange}_updates_(${dependencies.length})-${badgeColor}?style=for-the-badge \n` + )}`; + + results[packagePath] += `${lineBreak()}\n`; +}; + +// Output dependencies that need updating. +const outputDeps = (dependenciesObj, packagePath, isDevDep) => { + // Return if no dependencies to update. + if (dependenciesObj.outdated <= 0) return; + + // Output title. + results[packagePath] += `${lineBreak()}\n`; + if (isDevDep) + results[packagePath] += `${heading( + "Development Dependencies to Update:", + 3 + )}`; + else + results[packagePath] += `${heading( + "Production Dependencies to Update:", + 3 + )}`; + + // Output MAJOR depedencies to update. + const major = dependenciesObj.major; + if (major.length > 0) + outputDepsByVersionChange(major, "major", packagePath, isDevDep); + + // Output MINOR depedencies to update. + const minor = dependenciesObj.minor; + if (minor.length > 0) + outputDepsByVersionChange(minor, "minor", packagePath, isDevDep); + + // Output PATCH depedencies to update. + const patch = dependenciesObj.patch; + if (patch.length > 0) + outputDepsByVersionChange(patch, "patch", packagePath, isDevDep); +}; + +// Escape special characters for GitHub Actions. +const escapeForGitHubActions = (str) => + str.replace(/%/g, "%25").replace(/\n/g, "%0A").replace(/\r/g, "%0D"); + +(async () => { + // Create an array of promises for each packageJsonPath. + const promises = packageJsonPaths.map(async (packagePath) => { + results[packagePath] = ""; + + // Read the outdatedDeps file and get dependencies and devDependencies. + const deps = outdatedDeps[packagePath].deps ?? {}; + const devDeps = outdatedDeps[packagePath].devDeps ?? {}; + + // Output title. + results[packagePath] += `${heading(title, 2)}`; + results[packagePath] += `${line(subTitle)}\n`; + + // Get percentage of packages up to date. + const percentageUpToDate = calculateUpToDatePercentage( + deps.total + devDeps.total, + deps.outdated + devDeps.outdated + ); + + let percentageColor = green; + if (percentageUpToDate <= 50) percentageColor = red; + else if (percentageUpToDate <= 70) percentageColor = orange; + else if (percentageUpToDate <= 90) percentageColor = yellow; + + // Output percentage. + results[packagePath] += `${line("![COVERAGE_PERCENTAGE]\n")}`; + results[packagePath] += `${line( + `\n[COVERAGE_PERCENTAGE]: https://img.shields.io/badge/percentage_of_dependencies_up_to_date-${percentageUpToDate}-${percentageColor}?style=for-the-badge \n\n` + )}`; + + // Output summary. + if (deps.outdated === 0) + results[packagePath] += `${line(`${check} - Production ${upToDateMsg}`)}`; + else + results[packagePath] += `${line( + `${attention} - ${deps.outdated} Production ${outOfDateMsg}` + )}`; + + if (devDeps.outdated === 0) + results[packagePath] += `${line( + `${check} - Development ${upToDateMsg}` + )}`; + else + results[packagePath] += `${line( + `${attention} - ${devDeps.outdated} Development ${outOfDateMsg}` + )}`; + + // Output reminder. + if (deps.outdated > 0 || devDeps.outdated > 0) { + results[packagePath] += `${line( + `\n**Make sure to change directory to where the package.json is located using...**` + )}`; + results[packagePath] += `${codeBlock(`cd ${packagePath}`, "")}`; + } + + // Await the completion of output for both dependencies and devDependencies. + await Promise.all([ + outputDeps(deps, packagePath, false), + outputDeps(devDeps, packagePath, true), + ]); + + results[packagePath] = escapeForGitHubActions(results[packagePath]); + }); + + // Wait for all outputs to complete. + await Promise.all(promises); + + // Once all promises are resolved, log the results. + console.log(JSON.stringify(results, null, 2)); +})(); \ No newline at end of file diff --git a/.github/helpers/parse-json5-config.js b/.github/helpers/parse-json5-config.js new file mode 100644 index 0000000000..ac97f174fe --- /dev/null +++ b/.github/helpers/parse-json5-config.js @@ -0,0 +1,72 @@ +const fs = require("fs"); +const json5 = require("json5"); + +/** + * THIS FILE DOES NOT REQUIRE ANY EDITING. + * Place within .github/helpers/ + */ + +// Check if a file path is provided. +if (process.argv.length < 3) { + console.log("Usage: node parse-json5-config "); + process.exit(1); +} +const filePath = process.argv[2]; + +/** + * Read a Json5 file and parse out its values to a github workflow as output vars. + * + * Usage in GitHub Workflow: + * + * jobs: + * # Parse Output Vars from config. + * parse-json5-config: + * runs-on: ubuntu-22.04 + * outputs: + varFromConfig: ${{ steps.parse_config.outputs.varFromConfig }} + * + * steps: + * # Checkout branch. + * - name: Checkout Repository + * uses: actions/checkout@v4 + * + * # Install json5 npm package for parsing config. + * - name: Install Dependencies + * run: npm install json5 + * + * # Run script to convert json5 config to output vars. + * - name: Run Script + * id: parse_config + * run: node .github/helpers/parse-json5-config + * + * # Another job... + * another-job: + * runs-on: ubuntu-22.04 + * needs: parse-json5-config + * env: + * varFromConfig: ${{ needs.parse-json5-config.outputs.varFromConfig }} + * + */ +fs.readFile(filePath, "utf8", (err, data) => { + if (err) { + console.error("Error reading the file:", err); + return; + } + + try { + // Parse the JSON5 data + const jsonData = json5.parse(data); + + // Set each key-value pair as an environment variable + for (const [key, value] of Object.entries(jsonData)) { + // Serialize arrays and objects to JSON strings + const envValue = + typeof value === "object" ? JSON.stringify(value) : value; + + // Output each key-value pair for GitHub Actions + console.log(`::set-output name=${key}::${envValue}`); + } + } catch (parseError) { + console.error("Error parsing JSON5:", parseError); + } +}); \ No newline at end of file diff --git a/.github/helpers/parse-npm-deps.js b/.github/helpers/parse-npm-deps.js new file mode 100644 index 0000000000..8f59e53570 --- /dev/null +++ b/.github/helpers/parse-npm-deps.js @@ -0,0 +1,190 @@ +const https = require("https"); +const path = require("path"); + +const LOCAL_TEST = false; +const TEST_PACKAGEJSON_PATHS = ["src/frontend", "src/backend"]; +const TEST_IGNORE_PACKAGES = { + "src/frontend": [], + "src/backend": [], +}; + +/** + * THIS FILE DOES NOT REQUIRE ANY EDITING. + * Place within .github/helpers/ + * + * To test this file locally, + * - Set LOCAL_TEST variable to true. + * - Edit TEST_PACKAGEJSON_PATHS and TEST_IGNORE_PACKAGES if necessary. + * - From root, run "node .github/helpers/parse-npm-deps > outdatedDeps.json" + * - Check the outdatedDeps.json file, then delete it. + */ + +// Get package.json paths from env. +const packageJsonPaths = LOCAL_TEST + ? TEST_PACKAGEJSON_PATHS + : JSON.parse(process.env.packageJsonPaths); + +// Ignore packages from env. +const ignorePackages = LOCAL_TEST + ? TEST_IGNORE_PACKAGES + : JSON.parse(process.env.ignorePackages); + +// Save results to json. +let results = {}; + +// Save information on the dependency including latestVersion to the above results arrays. +const saveDependencyResults = ( + packagePath, + isDevDep, + dependency, + version, + latestVersion +) => { + // Create arrays of version triplets ie. 2.7.4 >> [2, 7, 4] + const versionTriplet = version.split("."); + const latestVersionTriplet = latestVersion.split("."); + + // Determine version change. + let versionChange = "patch"; + if (versionTriplet[0] !== latestVersionTriplet[0]) versionChange = "major"; + else if (versionTriplet[1] !== latestVersionTriplet[1]) + versionChange = "minor"; + + // Save results. + const saveInfo = { + dependency, + version, + latestVersion, + }; + + if (isDevDep) + results[packagePath].devDeps[versionChange].push(saveInfo); // devDep. + else results[packagePath].deps[versionChange].push(saveInfo); // dep. +}; + +// Check the latest version of each dependency. +const checkVersions = async (dependencyList, packagePath, isDevDep) => { + // For each dependency in the dependencyList. + for (let key in dependencyList) { + const [dependency, version] = dependencyList[key]; + const url = `https://registry.npmjs.org/${dependency}/latest`; + + // Skip dependency if in ignorePackages array. + if ( + ignorePackages.hasOwnProperty(packagePath) && + ignorePackages[packagePath].includes(dependency) + ) + continue; + + // Add to total. + if (isDevDep) ++results[packagePath].devDeps.total; + else ++results[packagePath].deps.total; + + try { + // Make an http request to the npm registry. + const data = await new Promise((resolve, reject) => { + https.get(url, (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + resolve(data); + }); + res.on("error", (error) => { + reject(error); + }); + }); + }); + + // Parse response data for latest version. + const latestVersion = JSON.parse(data).version; + + // Check if theres a difference in version and latestVersion. + if ( + latestVersion && + latestVersion !== "0.0.0" && + latestVersion !== version + ) { + if (!latestVersion.includes("-")) + saveDependencyResults( + packagePath, + isDevDep, + dependency, + version, + latestVersion + ); + else { + // Latest version includes '-'. + const data = await new Promise((resolve, reject) => { + https.get(`https://registry.npmjs.org/${dependency}`, (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + resolve(data); + }); + res.on("error", (error) => { + reject(error); + }); + }); + }); + + const versions = Object.keys(JSON.parse(data).versions); + // Remove all versions containing '-' and select the last item in the array. + const filteredLatestVersions = versions.filter( + (item) => !item.includes("-") + ); + const latestVersion = + filteredLatestVersions[filteredLatestVersions.length - 1]; + + if (latestVersion !== version) + saveDependencyResults( + packagePath, + isDevDep, + dependency, + version, + latestVersion + ); + } + + // Add to outdated sum. + if (isDevDep) ++results[packagePath].devDeps.outdated; + else ++results[packagePath].deps.outdated; + } + } catch (error) { + console.error(`Error checking ${dependency}: ${error.message}`); + } + } +}; + +(async () => { + // Create an array of promises for each packageJsonPath. + const promises = packageJsonPaths.map(async (packagePath) => { + const packageJson = require(path.resolve( + __dirname, + `../../${packagePath}/package.json` + )); + results[packagePath] = { + deps: { total: 0, outdated: 0, major: [], minor: [], patch: [] }, + devDeps: { total: 0, outdated: 0, major: [], minor: [], patch: [] }, + }; + + // Read the package.json file and get the list of dependencies and devDependencies. + const deps = Object.entries(packageJson.dependencies) ?? []; + const devDeps = Object.entries(packageJson.devDependencies) ?? []; + + // Await the completion of version checks for both dependencies and devDependencies. + await Promise.all([ + checkVersions(deps, packagePath, false), + checkVersions(devDeps, packagePath, true), + ]); + }); + + // Wait for all package checks to complete. + await Promise.all(promises); + + // Once all promises are resolved, log the results. + console.log(JSON.stringify(results, null, 2)); +})(); \ No newline at end of file