diff --git a/src/authors/academierenards.md b/src/authors/academierenards.md index 807e539f28..8a470bca42 100644 --- a/src/authors/academierenards.md +++ b/src/authors/academierenards.md @@ -2,5 +2,6 @@ name: "Marine Dunstetter" github: BlueCutOfficial twitter: academierenards +linkedin: mdunstetter bio: Senior Software Engineer --- diff --git a/src/posts/2024-01-30-ember-guides-fr-git-automation.md b/src/posts/2024-01-30-ember-guides-fr-git-automation.md new file mode 100644 index 0000000000..abf6cc6a56 --- /dev/null +++ b/src/posts/2024-01-30-ember-guides-fr-git-automation.md @@ -0,0 +1,1417 @@ +--- +title: "Automating the maintenance of the Ember Guides in French" +authorHandle: academierenards +tags: [ember, node, git] +bio: "Marine Dunstetter, Senior Software Engineer" +description: + "Marine Dunstetter builds an automation script in Node.js to maintain the + translation of the Ember Guides with git and GitHub." +og: + image: "/assets/images/posts/2024-01-30-ember-guides-fr-git-automation/og-image.jpg" +tagline: | +

+ One of the main blockers to translation projects is the overwhelming question: how do you maintain it? The tool you're building is already big, and the English docs for this tool are generally a complete website that you must keep up to date. What's the best approach to setting up translations? The answer's probably unique to each project. But how to make maintenance a realistic task? The answer contains a tiny bit of genericity: automation saves time. The Ember Guides are currently being translated into French, and what I built to automate the maintenance might give you some ideas. It's going to be about Node.js, git, and GitHub API. +

+ +image: "/assets/images/posts/2024-01-30-ember-guides-fr-git-automation/header.jpg" +imageAlt: + "The Ember logo on a grey background picture displaying a robot at work in a + factory" +--- + +As I am writing this blog post, +[the Ember Guides](https://guides.emberjs.com/release/) are being translated +into French. [The French website](https://ember-fr-guides.netlify.app) documents +only the latest version of Ember (for time and human resources purposes). It +means that when a new version of Ember is documented in English, we must adjust +the French website accordingly. Let's say we must _"catch up"_ with the English +version. + +At EmberFest 2023, I presented the approach used to build the French website, +and I described +[the manual process of catching up with English](https://www.youtube.com/watch?v=hTc_vH-AQdk&t=282s) +when a new version of the docs is released. However, such process is not a +realistic way to maintain the website in the long run. + +Let's go on a journey to automate everything with a Node.js script that will run +git commands and make requests to GitHub API! 🤖 + +**Disclaimer:** + +✅ This article will be a technical article to build a modern Node.js script +from scratch. It aims to be accessible to beginners~intermediates, and we'll use +the maintenance of the Ember Guides FR as a theme to introduce many concepts +like using a modern node version, running commands, managing files and making +API requests. However, it's not a "tutorial", because we create the script in an +existing app. We don't start from a blank page nor expect you to achieve exactly +the same thing. + +❌ This article won't be about how to bootstrap a translation project and how to +organize it to make it work in the long term. I can simply say that automating +the maintenance where it's possible and being confident in the tools you build +is a good way to remove pressure and entertain motivation. + +**Prerequisites:** + +- To automate the maintenance, we are going to execute a + [Node.js](https://nodejs.org/en) script to write instructions in JavaScript, + so we need to have Node.js installed. +- The article could be easier to follow if you first have a look at + [the part of the EmberFest talk related to git](https://www.youtube.com/watch?v=hTc_vH-AQdk&t=282s). + At the beginning of each section, I will sum up the information we need in a + small paragraph 🐹🎥 _The talk in a nutshell_. +- Finally, here is how my repository is configured. The French app's repo + (`ember-fr-guides-source`) is a fork of the official Ember Guides' repo + (`guides-source`): + +```bash +% git remote -v +origin git@github.com:DazzlingFugu/ember-fr-guides-source.git (fetch and push) +upstream git@github.com:ember-learn/guides-source.git (fetch and push) +``` + +## Table of contents + +- [1. Writing a modern Node.js script](#1.-writing-a-modern-node.js-script) + - [a. Running an mjs script](#a.-running-an-mjs-script) + - [b. Running a script with arguments](#b.-running-a-script-with-arguments) + - [c. Using an environment variable](#c.-using-an-environment-variable) +- [2. Using git in a Node.js script](#2.-using-git-in-a-node.js-script) + - [a. Running git commands from a script](#a.-running-git-commands-from-a-script) + - [b. Using git diff like a boss](#b.-using-git-diff-like-a-boss) + - [c. Managing new markdown files](#c.-managing-new-markdown-files) + - [d. Closing commands](#d.-closing-commands) +- [3. Managing files with Node.js](#3.-managing-files-with-node.js) + - [a. Reading files synchronously](#a.-reading-files-synchronously) + - [b. Creating files into a new folder](#b.-creating-files-into-a-new-folder) + - [c. Writing files asynchronously](#c.-writing-files-asynchronously) + - [d. Waiting for asynchronous operations with `map` and `Promise.all`](#d.-waiting-for-asynchronous-operations-with-map-and-promise.all) +- [4. Posting GitHub API requests with Node.js](#4.-posting-github-api-requests-with-node.js) + - [a. Preparing the payload](#a.-preparing-the-payload) + - [b. Using the fetch API](#b.-using-the-fetch-api) + - [c. Opening a PR with GitHub API](#c.-opening-a-pr-with-github-api) +- [Last words](#last-words) + +## 1. Writing a modern Node.js script + +🐹🎥 _The talk in a nutshell:_ A new version of the Ember Guides was published +🔥 What are the changes? To visualize them clearly, we can only compare English +to English: our repository has a protected `ref-upstream` branch that +corresponds to the English version under translation. We want to compare +`ref-upstream` to `upstream/master` to see the changes between both English +versions. If there are changes in files that are not translated yet, we want to +apply them automatically. If there are changes in files that have been +translated into French, we want GitHub to open an issue containing the diff. To +picture this part better, here is the kind of issue we want to open: +[#213 Translate /addons-and-dependencies/index.md, Ember 5.4](https://github.com/DazzlingFugu/ember-fr-guides-source/issues/213). + +Out of this summary, there are a couple of things we already know about our +script: + +- It will post an issue on GitHub, which is an asynchronous fetch operation. +- It will know the versions of Ember we are dealing with, so we can write some + beautiful "from Ember 5.4 to Ember 5.5" in the body of the issue. +- It will use GitHub API to post the issue. + +Let's start with what we know then. + +### a. Running an mjs script + +🤔 What’s your version of Node.js, by the way? + +You can check by running this command: + +```js +% node -v +``` + +At the moment I am writing this, the latest version is v21.5.0. It lets us use +modules and syntaxes like `async` and top-level `await` to bring clarity to our +asynchronous operations. This might be very handy to manage our calls to GitHub +API later, so let’s make sure we have the latest Node.js. The way to update +Node.js depends on the tools you used to install it (`brew`, `nvm`, `volta`...) +If, just like me, you never remember what it was, this could help: + +```js +% whereis node +``` + +If you're not familiar with running Node.js scripts, here are the basics. In the +`ember-fr-guides-source` folder, we create a file `scripts/catchup.mjs`: + +```js +// ember-fr-guides-source/scripts/catchup.mjs +console.log("I love Ember"); +``` + +ℹ️ The `m` in `mjs` stands for [Module](https://docs.fileformat.com/web/mjs/). +By using modules, we'll be able to use `import` to bring functionality from node +and external libraries into our script. + +Then, from the terminal, we reach the folder `ember-fr-guides-source` and run: + +```bash +% node scripts/catchup.mjs +> I love Ember +``` + +The output “I love Ember” shows up in the terminal. 🎉 + +### b. Running a script with arguments + +🤔 If we want to write "from Ember 5.4 to Ember 5.5" in the body of our issue, +then the script should know these numbers. First, we could declare two variables +`currentEmberVersion` and `newEmberVersion`. Then we could assign hardcoded +values `5.4` and `5.5`, but it would be inconvenient: each time we want to run +the catch-up process in the future, we would have to modify the script. It would +be better if we could specify the Ember versions in the command line. + +Before running a node script, you can add arguments separated by spaces. When +the script runs, the arguments will be accessible in `process.argv`. For +instance, let's add the following log at the beginning of `catchup.mjs`: + +```js +console.log(process.argv); +``` + +Then let's run: + +```bash +% node scripts/catchup.mjs --from 5.1 --to=5.4 -o hey +> [ + '/Users/***/node/v21.5.0/bin/node', + '/Users/***/ember-fr-guides-source/scripts/catchup.mjs', + '--from', + '5.1', + '--to=5.4', + '-o', + 'hey' +] +``` + +We could execute `process.argv.slice(2)` to remove the two first elements, then +read options by index or using the +[`indexOf`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf) +function. Another handy solution is to rely on an external library like +`minimist-lite`. This library maps the options we pass into a dictionary that +makes the access more intuitive in the code: + +```bash +% npm install minimist-lite --save-dev +``` + +ℹ️ This will add `minimist-lite` to the dev dependencies of our application. In +our case, `ember-fr-guides-source` is already an Ember application with a +`package.json`, so we have no problem running `npm install`. If you want to +import dependencies in a standalone script, outside of an application folder, +you'll need to initialize a package manager first. For instance, you can +initialize `npm` with `npm init`. + +We can now import `minimist-lite` in `catchup.mjs` and use it to map our +options: + +```js +import minimist from "minimist-lite"; + +var argv = minimist(process.argv.slice(2)); +console.log(argv); +``` + +```bash +% node scripts/catchup.mjs --from 5.0 --to=5.4 -o hey +> { _: [], from: 5, to: 5.4, o: 'hey' } +``` + +As you can see, passing an option like `--to=5.4` would result in +`argv.to === 5.4`, which is perfect. However, we have an issue with version +numbers ending with `0`: `--from 5.0` would result in `5`. This is because +`minimist-lite` manages numerical values as proper numbers. If we could indicate +that options `from` and `to` should be treated as `string` values, this would +solve our problem. `minimist-lite` can take options to let us specify the type +of each argument. The documentation is a bit hard to decipher in my personal +opinion, here is the syntax we need to figure out: + +```diff + import minimist from 'minimist-lite'; + +- var argv = minimist(process.argv.slice(2)); ++ var argv = minimist(process.argv.slice(2), { string: ['from', 'to'] }); + console.log(argv); +``` + +`string` is an option that contains the array of arguments that should be +handled as `string`: + +```bash +% node scripts/catchup.mjs --from 5.0 --to=5.4 -o hey +> { _: [], from: '5.0', to: '5.4', o: 'hey' } +``` + +Now that we have the expected format, let's store the arguments into variables +that we'll use later in our GitHub issue: + +```js +import minimist from "minimist-lite"; + +// Read script argument +const argv = minimist(process.argv.slice(2), { string: ["from", "to"] }); + +// Read current Ember version (under translation) +const currentEmberVersion = argv.from; +if (currentEmberVersion.match(/\d+[.]\d+/g)?.[0] !== currentEmberVersion) { + console.error( + "Error: please provide the current Ember version under translation to option --from (e.g. --from=5.1)" + ); + process.exit(9); +} +console.log(`Ember version under translation: ${currentEmberVersion}`); + +// Read new Ember version (documented by the English guides) +const newEmberVersion = argv.to; +if (newEmberVersion.match(/\d+[.]\d+/g)?.[0] !== newEmberVersion) { + console.error( + "Error: please provide the new Ember version documented on upstream to option --to (e.g. --to=5.4)" + ); + process.exit(9); // we'll come back to this line a bit later +} +console.log(`New Ember version documented on upstream: ${newEmberVersion}`); +``` + +ℹ️ `process.exit(9);` forces the process to exit. Any status but `0` indicates +the process exited with errors. `9` is for invalid arguments. You can see the +[list of error codes here](https://nodejs.org/api/process.html#exit-codes). +Also, be aware that if you start async operations in a script and +`process.exit(n);` executes, then you have no way to know for sure what +operations the script could perform or not before existing. If for some reason +all your async operations are not awaited, it's generally better to assign a +value to `process.exitCode`. `process.exitCode = n;` means that _when the +process finally exits_, it will exit with code `n`. + +ℹ️ +[`match`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match) +finds all the occurrences of a given regexp in the string and returns them in an +array. In our case, we expect the first occurrence of `d.d` (digit, dot, digit) +to exist and to be equal to the argument value: + +```bash +% node scripts/catchup.mjs --from=5.1typo --to=5.4 +> Error: please provide the current Ember version under translation to option --from (e.g. --from=5.1) + +% node scripts/catchup.mjs --from=5.1 --to=5.4 +> Ember version under translation: 5.1 +> New Ember version documented on upstream: 5.4 +``` + +Great! Now we have the first piece of information to create a GitHub issue. + +### c. Using an environment variable + +Most of the time, when we want to create a new GitHub issue on a repository, we +do it manually via GitHub UI. We can achieve the same thing using GitHub API. +[Here is what it looks like](https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#create-an-issue). +As you can see in the JavaScript tab of the docs, GitHub recommends the usage of +`Octokit` to interact with the API in JavaScript. + +However, our case is pretty simple, and using the regular JavaScript method +`fetch` with [https://api.github.com/](https://api.github.com/) is also +possible. I decided to go with `fetch` because I think it’s a bit more +accessible and generic, you could reuse this blog post more easily for different +purposes. It’s essentially the same thing, except that we'll set our +authorization header manually. Among other static things, the authorization +header contains an `Authorization` field that must be filled with +`Bearer `. + +`` should be replaced with a secret token required by GitHub API to +authenticate us and make sure we're allowed to use the API the way we try to use +it. To generate a personal token, log in to your GitHub account and +[follow this section of the docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic). +Once your token is generated, copy it somewhere, in a local file, so you have it +within easy reach. + +🤔 `` is not supposed to change over time (at least not very often), +but it’s sensitive, so it can’t be hardcoded in the script. We could pass a +`token` as third argument: + +```bash +% node scripts/togithub.mjs --from=5.1 --to=5.4 --token="" +``` + +It’s fine, but not good enough. We don’t want to copy the `token` manually from +somewhere to our command line each time we want to run the script. It would be +more convenient if the script could read the token by itself. + +A common practice in web projects is to store the secret variables in a file +`.env` which is added to the `.gitignore` file so we’re sure it won’t be +committed to GitHub inadvertently. Along with this file, an `.env.example`, +which contains fake values, is committed to GitHub and describes what secret +variables the developer should set up to get the project working. The library +[dotenv](https://www.npmjs.com/package/dotenv) brings this common practice into +Node.js by providing the variables defined in `.env` through `process.env`: + +```bash +% npm install dotenv --save-dev +``` + +```bash +// .env +GITHUB_TOKEN="" +``` + +❗ Make sure to add `.env` in the `.gitignore` file if it’s missing! + +```js +import "dotenv/config"; + +console.log(process.env.GITHUB_TOKEN); +``` + +```bash +% node scripts/catchup.mjs --from=5.1 --to=5.4 +> "" +``` + +Let's remove that `console.log`. The token can be read anywhere in the script, +for instance in a function: + +```js +/* + * This function returns the headers required for all requests to GitHub API. + * It includes both posting issues and opening the catch-up PR. + */ +const getRequestHeaders = () => { + return { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + "X-GitHub-Api-Version": "2022-11-28", + }; +}; +``` + +So far, we learned how to: + +- execute a modern `mjs` script +- install and import external libraries +- pass and parse script arguments +- read environment variables + +The script knows all the arguments it needs to automate the process 🎉 + +## 2. Using git in a Node.js script + +🐹🎥 _The talk in a nutshell:_ A new version of the Ember Guides was published +🔥 To see the changes, we compare `origin/ref-upstream` to `upstream/master` and +we print the result in a patch file `english.diff` that shows a list of diff +blocks. If non-translated files have changes, we apply the changes +automatically, which means these files are updated locally, which means we have +to commit them in a working branch. Once that's done, we are officially +translating the new Ember version, so we reset `origin/ref-upstream` to the same +state as `upstream/master` to be ready for the next catch-up. + +Sounds like the script is going to run a lot of git commands: + +- It will create and switch to a new working branch. +- It will generate a patch file by comparing two branches. +- It will try to apply a patch file automatically. +- It will commit and push files. +- It will reset `origin/ref-upstream` to `upstream/master`. + +### a. Running git commands from a script + +First, we want to create a new "catch-up" branch to work on. In the terminal, we +would do this with `git switch --create `. It would be nice to +reuse our variable `newEmberVersion` in the name of the branch, both for the +sake of clarity and to have the possibility to keep several catch-up branches +locally if we need to. Unfortunately, we can’t write the shell commands we use +in our terminal directly in the `catchup.mjs` file, because it’s not JavaScript! +The good news is Node.js embeds a function to answer this problem. It's called +`execSync`, and it's located in the `child_process` module: + +```js +import { execSync } from "child_process"; + +const catchupBranch = `catchup-${newEmberVersion}`; + +try { + console.log(`Attempting to execute: "git switch --create ${catchupBranch}"`); + execSync(`git switch --create ${catchupBranch}`); +} catch (error) { + console.error( + `Failed to prepare the git branches for the catch-up. This was caused by: ${error}` + ); + process.exitCode = 1; +} +``` + +ℹ️ `Sync` stands for synchronous. It means the script execution will pause until +the operation is complete. Node.js generally provides asynchronous functions +along with a `Sync` version, so you can use one or the other (e.g. `exec` vs +`execSync`). We'll come back to this later. + +Our code will work once. If we call it twice, it will fail because the branch +already exists. When the command specified to `execSync` fails, it throws an +error, which could cause the process to exit immediately. We prevent this +behavior with the +[`try...catch`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/try...catch) +block and customize the error message. + +Great! We can run git commands! 💪 We also want to run `git fetch` and +`git fetch upstream` to make sure that `origin/ref-upstream` and +`upstream/master` are both up to date before we start working with them. Let's +abstract our logic in a function so we don't have to rewrite the log each time: + +```diff ++ const runShell = (command) => { ++ console.log(`Attempting to execute: "${command}"`); ++ execSync(`${command}`); ++ } + + try { +- console.log(`Attempting to execute: "git switch --create ${catchupBranch}"`); +- execSync(`git switch --create ${catchupBranch}`); ++ // Create a catch-up branch out of the current branch (should be up to date master) ++ runShell('git switch --create ${catchupBranch}'); ++ // Fetch the latest ref-upstream branch (English version under translation on this repo) ++ runShell('git fetch'); ++ // Fetch the latest updates in the official Ember Guides ++ runShell('git fetch upstream'); + } + // catch(error) { ... } +``` + +### b. Using git diff like a boss + +The next step of the process is to generate the diff between +`origin/ref-upstream` and `upstream/master` to view how the second impacts the +first. In the talk, I gave several steps to build this command. The one we'll +describe here is slightly different but does essentially the same thing: + +```bash +git diff origin/ref-upstream upstream/master -- guides/release > english.diff +``` + +`git diff A B` compares one branch to another in the order A is impacted by B: + +```diff +- Line number 24 in file myFile.md on branch A ++ Line number 24 in file myFile.md on branch B +``` + +The `--` option allows us to specify a path for the compare operation. We are +not interested in all the changes in the whole app, we want to patch only the +markdown files located in `guides/release`. + +Then we output the result of this command in one big file called `english.diff`, +which shows up at the root of the project. It contains one diff block per +modified markdown file. This is where the manual process becomes a bit heavy: + +- We run `git apply english.diff` to automatically apply the changes A → B to + all files. But git can do this only if each target file is in English and + hasn’t been translated yet. If at least one markdown file has been translated + into French, it means both sides have been modified and are now completely + different, so git doesn’t know what to do: the whole command fails. +- For each block causing a failure, we open manually a new issue on GitHub, copy + the diff in there (so a translator can tackle it later), and then remove the + block from `english.diff`. Then we run `git apply english.diff` again, etc, + etc... +- We do this until `git apply english.diff` finally works because there are only + non-translated English files targeted by the remaining diff blocks. + +🤔 This iterative process doesn't look easy to automate. We probably need a +quite different strategy, and we can probably make it simpler to picture! + +`git apply` takes a path to a patch file. Instead of running +`git apply english.diff` and the whole command fails when something goes wrong, +it would be more convenient to try `git apply` block by block. What if we could +have one patch file per markdown file? This way we could run +`git apply file-1.diff`, `git apply file-2.diff`... and perform actions +depending on the result. + +Thanks to `-- guides/release` option, we learned that `git diff` command can +scope the comparison to a given folder. We could also scope it to one single +markdown file! So we would run the command with +`-- [my-file-1].md > [my-file-1].diff` to output all the different patches. But +how to get the list of markdown files that have been modified in +`upstream/master`? + +`git diff` can help us with another option called `--name-only`. Instead of +returning all the diff blocks as we saw earlier, it will return only the name of +the files containing changes! + +To sum it up, here is the kind of implementation we want: + +```js +/* + * This function compares the given filename in both branches and output an [index].diff file. + * Example: + * filename = guides/accessibility/index.md, index = 3 + * The diff between ref-upstream and upstream/master for guides/accessibility/index.md is printed in 3.diff + */ +const createDiff = (filename, index) => { + const diffName = `${index}.diff`; + try { + runShell(`git diff origin/ref-upstream upstream/master -- ${filename} > ${diffName}`); + return diffName; + } catch (error) { + throw new Error(`Failed to create the diff for ${filename}. This was caused by: ${error}`); + } +} + +/* + * This function generates a patch file for each file in files (using createDiff) + * then tries to run 'git apply' for each patch. + */ +const applyPatches = (files) => { + files.forEach((filename, index) => { + try { + let diffName = createDiff(filename, index); + try { + runShell(`git apply ${diffName}`); + } catch (error) { + console.log(`"git apply" command failed for ${diffName}`); // we prefer console.log over console.warn, because failure is part of the normal process + // TODO: we'll need to keep track of this patch file to create a GitHub issue + } + } catch (error) { + console.warn(error); + } + }); +} + +try { + // Create catch-up branch and fetch last changes { ... } + + // Output the list of markdown files impacted by latest changes on upstream + runShell('git diff --name-only origin/ref-upstream upstream/master -- guides/release > list.diff'); + + // List of filenames that changed between origin/ref-upstream and upstream/master + let files = /* TODO: read the content of 'list.diff' and parse it as an array of filename. */; + + if (files?.length > 0) { + applyPatches(files); + } else { + console.log('No change between both versions of the Ember Guides.'); + } +} // catch(error) { ... } +``` + +That's a great start! There's just this blocking `TODO` (read the content of +`list.diff`) we need to figure out, we'll come back to it in the next section. + +### c. Managing new markdown files + +🤔 We saw quite interesting `git diff` options to create all the resources we +need. There's one case though, we didn't mention at all. What if some of the +"changed" files are new files that have been added to the latest Ember docs and +that don't exist at all in our current branch? + +In that case, `git apply` will work like a charm and will create the new English +file for us, but then... we'll need to open a GitHub issue to alert the +translators there's something new to translate entirely. It means that the +answer to the question "Should I open a GitHub issue?" doesn't depend only on +`git apply`'s success or failure, it also depends on the existence of the file +in the current branch, and at some point, we should probably track the files +that need to be posted. + +To check if the file exists on the current branch, we'll import a module called +`fs` and use the function called `existsSync`. We'll learn more about the `fs` +module in the next section of the blog post 😉 + +```diff ++ import fs from 'fs'; + + const applyPatches = (files) => { ++ let filesToPost = []; + files.forEach((filename, index) => { + try { + let diffName = createDiff(filename, index); + try { ++ let isNew = !fs.existsSync(filename); + runShell(`git apply ${diffName}`); ++ if (isNew) filesToPost.push(filename); + } catch (error) { + console.log(`"git apply" command failed for ${diffName}`); // we prefer console.log over console.error, because failure is part of the normal process ++ filesToPost.push(filename); + } + } catch (error) { + console.warn(error); + } + }); ++ return filesToPost; + } + + // { ... } + +- applyPatches(files); ++ let filesToPost = applyPatches(files); + +``` + +ℹ️ `git apply` runs after we've initialized `isNew`, because it can create the +missing file and therefore compromise the value. + +### d. Closing commands + +There are other git commands we need to include in the script: those happening +at the end of the process: + +- If we have local changes, we should commit them and push the catch-up branch + to GitHub: this happens when at least one `git apply` worked. +- Once we're done with everything, we should reset `origin/ref-upstream` to + `upstream/master` so we are ready for the next catch-up: this happens each + time the process ends without error, even when there were no changes at all in + `guides/release`. + +There's no new concept here, we already have all the knowledge to put this in +place: + +```js +/* + * This function performs the last actions once most of the catch-up is done. + * It updates origin/ref-upstream to upstream/master if there's no pending manual action, + * then it switches back to master. + */ +const closeProcess = pendingDiff => { + if (!pendingDiff) { + // This resets the ref-upstream branch to the latest upstream/master + try { + runShell("git switch ref-upstream"); + runShell("git reset --hard upstream/master"); + runShell("git push origin -f ref-upstream"); + } catch (error) { + throw new Error( + "Failed to reset ref-upstream to the latest upstream/master" + ); + } + + // Then switches back to master + try { + runShell("git switch master"); + } catch (error) { + console.warn( + "The process is complete, but failed to switch back to master" + ); + } + } +}; +``` + +We want `applyPatches` to tell us if at least one patch file couldn't be created +and if at least one patch could be applied automatically. There are certainly +different ways to achieve this, but for reasons we'll see in a later section of +this blog post, let's go with a result code approach: + +```diff + const applyPatches = (files) => { + let filesToPost = []; ++ let results = []; + files.forEach((filename, index) => { + try { + let diffName = createDiff(filename, index); + try { + let isNew = !fs.existsSync(filename); + runShell(`git apply ${diffName}`); + if (isNew) filesToPost.push(filename); ++ results.push(0); + } catch (error) { + console.log(`"git apply" command failed for ${diffName}`); + filesToPost.push(filename); ++ results.push(2); + } + } catch (error) { + console.warn(error); ++ results.push(1); + } + }); +- return filesToPost; ++ return { ++ filesToPost, ++ hasAutoApply: result.some((status) => status === 0); ++ hasPendingDiff: result.some((status) => status === 1); ++ } + } + + // { ... } + ++ // True if origin/ref-upstream branch cannot be updated because we need some manual checks between ref-upstream and upstream/master ++ let pendingDiff = false; + + if (files && files.length > 0) { +- let filesToPost = applyPatches(files); ++ let { filesToPost, hasAutoApply, hasPendingDiff } = applyPatches(files); ++ pendingDiff = hasPendingDiff; ++ if (hasAutoApply) { ++ // This adds, commits, and pushes changes in "guides" folder ++ try { ++ runShell("git add guides"); ++ runShell(`git commit -m "feat: automatic catch up from ${currentEmberVersion} to ${newEmberVersion}"`); ++ runShell(`git push origin ${catchupBranch}`); ++ // TODO: if the changes have been pushed without error, then we can open a catch-up PR on GitHub ++ } catch (error) { ++ throw new Error("Failed to push the catch-up branch"); ++ } ++ } + } else { + console.log('No change between both versions of the Ember Guides.'); + } + ++ closeProcess(pendingDiff); + + // { ... } +``` + +So far, we learned how to: + +- Run a shell command with `execSync` +- Output the result of a command in a file +- Compare branches with `git diff` and a panel of options to make it output + information in different shapes + +In theory, git does a lot of things for us at this point. "In theory" because +our array of `files` is still desperately empty! Let's do something about that. + +## 3. Managing files with Node.js + +🐹🎥 _The talk in a nutshell:_ In the French website, we translate only the +latest version of Ember, so the UI doesn't have a dropdown to navigate to legacy +versions. To hide the dropdown automatically, our folders have a slightly +different scaffolding. The subtlety is that our diff compares the markdown files +located in `guides/release/`. But in our `master` branch (and our `catchup` +branch), all these very same files are located directly in `guides/`. It means +that the command `git apply ${diffName}` will fail to patch the updated files, +because the script will look for them in `guides/release/` and notice they don't +exist. + +Here are the conclusions we can draw: + +- We need to read `list.diff` and parse the list of filenames in our `files` + array. Each item of the array will be a filename starting with + `guides/release/`. +- To make our patch files valid in our scaffolding, we need a way to change the + path to `guides/`. +- If a patch file cannot be applied automatically, we'll reuse its content to + create the corresponding GitHub issue. And if we fail to post one GitHub issue + among several, it will be easier to open it manually (trying to re-run the + script for one given file is currently out of scope), so we'll keep the patch + files easy to reach. + +### a. Reading files synchronously + +We want to read the content of `list.diff` to extract an array of filenames. To +deal with files, we’re going to use +[Node.js File System Module](https://nodejs.org/docs/latest-v21.x/api/fs.html#file-system) +`fs`. It's the module we imported earlier, when we used `fs.existsSync`! Among +all the functions this module provides, here are the ones we are interested in +this time: `fs.readFile` and `fs.readFileSync`, which can read a file and return +its content. + +ℹ️ Just like we saw in the first section with `exec` versus `execSync`, most +functions of the `fs` module are asynchronous and have a `Sync` alternative. The +asynchronous functions should generally be preferred whenever it’s possible to +use them because they allow the process to parallelize some tasks and therefore +be faster. + +But in our case, synchronicity makes more sense: as long as we don’t know the +content of `list.diff`, we can’t know the list of files to handle next. +`fs.readFileSync` retrieves the file out of the provided `filename` and returns +its content: + +```js +// Read list.diff to extract the list of paths to markdown files +let data = fs.readFileSync("list.diff", "utf8"); +const files = data.split(/[\n\r]/).filter(name => name.length); +``` + +ℹ️ The first argument of `fs.readFileSync` is a relative path. `list.diff` is +like `./list.diff`. Also, note that `./` is not the location of the +`catchup.mjs` script, but it’s from where we are running the script. It means +that if we are at the root of the `ember-fr-guides-source` folder and we use the +command `node scripts/catchup.mjs` from there, then `./` is exactly where we +are: the root of the `ember-fr-guides-source` folder. Here, the filename is +`./list.diff` because we generated it at the root of the repository. + +The content of `list.diff` contains one line per file name: using a +[`split`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split) +by line breakers then filtering potential empty values is enough to create our +array. + +We have our `files` ready, which means we can finally enter the function +`applyPatches` to generate the patch file `[index].diff` for each of them. + +🤔 But don't you think it's a bit rough to create all our files at the root of +the project like this? Couldn't we create our patch files in a dedicated folder? + +### b. Creating files into a new folder + +When you need to create _temporary_ files and folders that are used only by the +script and then can be deleted at the end of the process, you can use +[the OS directory for temporary files](https://nodejs.org/api/os.html#ostmpdir) +to ease the clean-up. Our patch files are not necessarily that kind of temporary +files. Let's imagine we reuse these files to create 3 GitHub issues and for some +reason one of the POST requests fails. Today, the easiest way to finish the +catch-up is to open manually the missing issue, so having the patch file still +around will become handy. For this reason, we will create the folder permanently +on the catch-up branch. + +Let’s create a new folder in the scripts folder: + +```js +// Create a directory to put the children diff +fs.mkdirSync("scripts/patches", { recursive: true }); +console.log("scripts/patches folder created to store the patch files"); +``` + +ℹ️ Using the option `recursive: true` will prevent the command from failing if +the `scripts` folder doesn't exist or there's already a `scripts/patches` +folder. It's up to you to decide if that case is a problem or not. + +We now have a good place for our files, so we can create them here: + +```diff + const createDiff = (filename, index) => { +- const diffName = `${index}.diff`; ++ const diffName = `scripts/patches/${index}.diff`; + try { + runShell(`git diff origin/ref-upstream upstream/master -- ${filename} > ${diffName}`); + return diffName; + } // catch (error) { ... } + } +``` + +The patch files are created in the `scripts/patches` folder. Once the file is +written, `git apply` runs and... fails with the error "No such file or +directory". Time to take care of this `guides` versus `guides/release` thing. + +### c. Writing files asynchronously + +The content of a patch file looks like this: + +```diff +diff --git a/guides/release/accessibility/components.md b/guides/release/accessibility/components.md +index 2253b1bdd..6e0e2a99d 100644 +--- a/guides/release/accessibility/components.md ++++ b/guides/release/accessibility/components.md +@@ -57,13 +57,15 @@ However, the most common methods for providing accessible names can be reviewed + + ### Adding a label to an input element + +-The `` element's `id` attribute value should be the same as the `for` attribute value on the `