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;