/ .github / workflows / ci-merge.js
ci-merge.js
  1  // Note: This is a GitHub Actions script
  2  // It is not meant to be executed directly on your machine without modifications
  3  
  4  const fs = require("fs");
  5  // how far back in time should we consider the changes are "recent"? (default: 24 hours)
  6  const DETECTION_TIME_FRAME = (parseInt(process.env.DETECTION_TIME_FRAME)) || (24 * 3600 * 1000);
  7  
  8  async function checkBaseChanges(github, context) {
  9      // query the commit date of the latest commit on this branch
 10      const query = `query($owner:String!, $name:String!, $ref:String!) {
 11          repository(name:$name, owner:$owner) {
 12              ref(qualifiedName:$ref) {
 13                  target {
 14                      ... on Commit { id committedDate oid }
 15                  }
 16              }
 17          }
 18      }`;
 19      const variables = {
 20          owner: context.repo.owner,
 21          name: context.repo.repo,
 22          ref: 'refs/heads/master',
 23      };
 24      const result = await github.graphql(query, variables);
 25      const committedAt = result.repository.ref.target.committedDate;
 26      console.log(`Last commit committed at ${committedAt}.`);
 27      const delta = new Date() - new Date(committedAt);
 28      if (delta <= DETECTION_TIME_FRAME) {
 29          console.info('New changes detected, triggering a new build.');
 30          return true;
 31      }
 32      console.info('No new changes detected.');
 33      return false;
 34  }
 35  
 36  async function checkCanaryChanges(github, context) {
 37      if (checkBaseChanges(github, context)) return true;
 38      const query = `query($owner:String!, $name:String!, $label:String!) {
 39          repository(name:$name, owner:$owner) {
 40              pullRequests(labels: [$label], states: OPEN, first: 100) {
 41                  nodes { number headRepository { pushedAt } }
 42              }
 43          }
 44      }`;
 45      const variables = {
 46          owner: context.repo.owner,
 47          name: context.repo.repo,
 48          label: "canary-merge",
 49      };
 50      const result = await github.graphql(query, variables);
 51      const pulls = result.repository.pullRequests.nodes;
 52      for (let i = 0; i < pulls.length; i++) {
 53          let pull = pulls[i];
 54          if (new Date() - new Date(pull.headRepository.pushedAt) <= DETECTION_TIME_FRAME) {
 55              console.info(`${pull.number} updated at ${pull.headRepository.pushedAt}`);
 56              return true;
 57          }
 58      }
 59      console.info("No changes detected in any tagged pull requests.");
 60      return false;
 61  }
 62  
 63  async function tagAndPush(github, owner, repo, execa, commit=false) {
 64      let altToken = process.env.ALT_GITHUB_TOKEN;
 65      if (!altToken) {
 66          throw `Please set ALT_GITHUB_TOKEN environment variable. This token should have write access to ${owner}/${repo}.`;
 67      }
 68      const query = `query ($owner:String!, $name:String!) {
 69          repository(name:$name, owner:$owner) {
 70              refs(refPrefix: "refs/tags/", orderBy: {field: TAG_COMMIT_DATE, direction: DESC}, first: 10) {
 71                  nodes { name }
 72              }
 73          }
 74      }`;
 75      const variables = {
 76          owner: owner,
 77          name: repo,
 78      };
 79      const tags = await github.graphql(query, variables);
 80      let lastTag = tags.repository.refs.nodes[0].name;
 81      let tagNumber = /\w+-(\d+)/.exec(lastTag)[1] | 0;
 82      let channel = repo.split('-')[1];
 83      let newTag = `${channel}-${tagNumber + 1}`;
 84      console.log(`New tag: ${newTag}`);
 85      if (commit) {
 86          let channelName = channel[0].toUpperCase() + channel.slice(1);
 87          console.info(`Committing pending commit as ${channelName} #${tagNumber + 1}`);
 88          await execa("git", ['commit', '-m', `${channelName} #${tagNumber + 1}`]);
 89      }
 90      console.info('Pushing tags to GitHub ...');
 91      await execa("git", ['tag', newTag]);
 92      await execa("git", ['remote', 'add', 'target', `https://${altToken}@github.com/${owner}/${repo}.git`]);
 93      await execa("git", ['push', 'target', 'master', '-f']);
 94      await execa("git", ['push', 'target', 'master', '-f', '--tags']);
 95      console.info('Successfully pushed new changes.');
 96  }
 97  
 98  async function generateReadme(pulls, context, mergeResults, execa) {
 99      let baseUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/`;
100      let output =
101          "| Pull Request | Commit | Title | Author | Merged? |\n|----|----|----|----|----|\n";
102      for (let pull of pulls) {
103          let pr = pull.number;
104          let result = mergeResults[pr];
105          output += `| [${pr}](${baseUrl}/pull/${pr}) | [\`${result.rev || "N/A"}\`](${baseUrl}/pull/${pr}/files) | ${pull.title} | [${pull.author.login}](https://github.com/${pull.author.login}/) | ${result.success ? "Yes" : "No"} |\n`;
106      }
107      output +=
108          "\n\nEnd of merge log. You can find the original README.md below the break.\n\n-----\n\n";
109      output += fs.readFileSync("./README.md");
110      fs.writeFileSync("./README.md", output);
111      await execa("git", ["add", "README.md"]);
112  }
113  
114  async function fetchPullRequests(pulls, repoUrl, execa) {
115      console.log("::group::Fetch pull requests");
116      for (let pull of pulls) {
117          let pr = pull.number;
118          console.info(`Fetching PR ${pr} ...`);
119          await execa("git", [
120              "fetch",
121              "-f",
122              "--no-recurse-submodules",
123              repoUrl,
124              `pull/${pr}/head:pr-${pr}`,
125          ]);
126      }
127      console.log("::endgroup::");
128  }
129  
130  async function mergePullRequests(pulls, execa) {
131      let mergeResults = {};
132      console.log("::group::Merge pull requests");
133      await execa("git", ["config", "--global", "user.name", "citrabot"]);
134      await execa("git", [
135          "config",
136          "--global",
137          "user.email",
138          "citra\x40citra-emu\x2eorg", // prevent email harvesters from scraping the address
139      ]);
140      let hasFailed = false;
141      for (let pull of pulls) {
142          let pr = pull.number;
143          console.info(`Merging PR ${pr} ...`);
144          try {
145              const process1 = execa("git", [
146                  "merge",
147                  "--squash",
148                  "--no-edit",
149                  `pr-${pr}`,
150              ]);
151              process1.stdout.pipe(process.stdout);
152              await process1;
153  
154              const process2 = execa("git", ["commit", "-m", `Merge PR ${pr}`]);
155              process2.stdout.pipe(process.stdout);
156              await process2;
157  
158              const process3 = await execa("git", ["rev-parse", "--short", `pr-${pr}`]);
159              mergeResults[pr] = {
160                  success: true,
161                  rev: process3.stdout,
162              };
163          } catch (err) {
164              console.log(
165                  `::error title=#${pr} not merged::Failed to merge pull request: ${pr}: ${err}`
166              );
167              mergeResults[pr] = { success: false };
168              hasFailed = true;
169              await execa("git", ["reset", "--hard"]);
170          }
171      }
172      console.log("::endgroup::");
173      if (hasFailed) {
174          throw 'There are merge failures. Aborting!';
175      }
176      return mergeResults;
177  }
178  
179  async function mergebot(github, context, execa) {
180      const query = `query ($owner:String!, $name:String!, $label:String!) {
181          repository(name:$name, owner:$owner) {
182              pullRequests(labels: [$label], states: OPEN, first: 100) {
183                  nodes {
184                      number title author { login }
185                  }
186              }
187          }
188      }`;
189      const variables = {
190          owner: context.repo.owner,
191          name: context.repo.repo,
192          label: "canary-merge",
193      };
194      const result = await github.graphql(query, variables);
195      const pulls = result.repository.pullRequests.nodes;
196      let displayList = [];
197      for (let i = 0; i < pulls.length; i++) {
198          let pull = pulls[i];
199          displayList.push({ PR: pull.number, Title: pull.title });
200      }
201      console.info("The following pull requests will be merged:");
202      console.table(displayList);
203      await fetchPullRequests(pulls, "https://github.com/citra-emu/citra", execa);
204      const mergeResults = await mergePullRequests(pulls, execa);
205      await generateReadme(pulls, context, mergeResults, execa);
206      await tagAndPush(github, context.repo.owner, `${context.repo.repo}-canary`, execa, true);
207  }
208  
209  module.exports.mergebot = mergebot;
210  module.exports.checkCanaryChanges = checkCanaryChanges;
211  module.exports.tagAndPush = tagAndPush;
212  module.exports.checkBaseChanges = checkBaseChanges;