preview/clean.js (199 lines of code) (raw):
'use strict';
const child_process = require('child_process');
const dedent = require('dedent');
const https = require('https');
const os = require('os');
const path = require('path');
module.exports = { Cleaner, exec_git };
const EXPIRE_DAYS = 31;
function Cleaner(token, repo, cache_dir, tmp_dir) {
let repo_name = path.basename(repo);
if (!repo_name.endsWith('.git')) {
repo_name += '.git';
}
const local_path = `${tmp_dir}/${repo_name}`;
const clone = () => {
return exec_git([
'clone',
'--bare',
'--reference', `${cache_dir}/${repo_name}`,
repo, local_path])
}
const show_heads = () => {
return exec_git(['show-ref', '--heads'], { cwd: local_path });
}
const heads_to_prs = (heads) => {
return heads
.split('\n')
.reduce((acc, line) => {
const found = line.match(/^.+ refs\/heads\/((.+)_(\d+))$/);
if (found) {
acc.push({
branch: found[1],
// Temporary hack during the Buildkite migration that breaks the <repo>_<PR> convention,
// and introduces the <repo>_BK_<PR> convention - see https://github.com/elastic/docs-projects/issues/134
repo: found[2].includes('_bk') ? found[2].replace('_bk', '') : found[2],
number: Number(found[3]),
});
}
return acc;
}, [])
.sort((lhs, rhs) => {
if (lhs.repo < rhs.repo) {
return -1;
}
if (lhs.repo > rhs.repo) {
return 1;
}
return lhs.number - rhs.number;
});
}
const cleanup_closed_prs = async prs => {
const now = Date.now() / 1000;
for (let pr of prs) {
const url = `https://www.github.com/elastic/${pr.repo}/pull/${pr.number}`;
const age = await prAge(pr);
const days = (now - age) / 24 / 60 / 60;
if (days > EXPIRE_DAYS) {
console.log(`Deleting ${pr.branch} for ${days.toFixed(2)} days old pr at ${url}`);
deleteBranch(pr);
} else if (await is_pr_closed(pr)) {
console.info(`Deleting ${pr.branch} for closed pr at ${url}`);
deleteBranch(pr);
} else {
console.info(`Preserving ${pr.branch} for open pr at ${url}`);
}
}
}
const prAge = async function (pr) {
return parseInt(await exec_git(
[
"show", "--pretty=%ad", "--no-notes", "--no-patch", "--date=unix",
pr.branch
],
{ cwd: local_path }
));
}
const deleteBranch = async function (pr) {
if (pr.branch === 'master' || pr.branch === 'staging') {
// Just for super double ultra paranoia.
throw "Can't delete master!";
}
await exec_git(
['push', 'origin', '--delete', pr.branch],
{ cwd: local_path }
);
}
const is_pr_closed = function (pr) {
console.info(`Checking status of https://github.com/elastic/${pr.repo}/pull/${pr.number}`);
return new Promise((resolve, reject) => {
const body = {
query: `
query PRClosed($repo: String!, $number: Int!) {
repository(owner:\"elastic\", name:$repo) {
pullRequest(number:$number) {
closed
}
}
}
`,
variables: {
repo: pr.repo,
number: pr.number,
},
};
const postData = JSON.stringify(body);
const req = https.request({
method: 'POST',
host: 'api.github.com',
path: '/graphql',
headers: {
'Authorization': `bearer ${token}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'Elastic Docs Preview Cleaner Upper',
}
}, res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode !== 200) {
reject(`Error getting ${JSON.stringify(pr)} [${res.statusCode}]:\n${data}`);
} else {
let closed;
try {
const parsed = JSON.parse(data);
const repo = parsed.data.repository;
if (repo) {
closed = repo.pullRequest.closed;
} else {
console.warn(pr.branch,
"looks like a PR but isn't for a repo we manage so we assume",
'it is open');
closed = false;
}
} catch (e) {
reject(e);
return;
}
if (closed === undefined) {
reject(`unexpected reply from github:${data}`);
return;
}
if (res.headers['x-ratelimit-remaining'] < 100) {
const until = res.headers['x-ratelimit-reset'];
const millis = until * 1000 - Date.now().getTime();
console.info('Rate limited for', millis, 'milliseconds');
setTimeout(() => resolve(closed), millis);
} else {
resolve(closed);
}
}
});
});
req.on('error', err => {
reject(`Error getting ${JSON.stringify(pr)}:\n${err}`);
});
req.write(postData);
req.end();
});
}
return {
run: () => {
return clone()
.then(show_heads)
.then(heads_to_prs)
.then(cleanup_closed_prs);
},
clone: clone,
is_pr_closed: is_pr_closed,
repo: repo,
local_path: local_path,
};
}
function exec_git(opts, env = {}) {
return new Promise((resolve, reject) => {
child_process.execFile('git', opts, env, (err, stdout, stderr) => {
if (err) {
reject(dedent`
err [${err}] running [git ${opts.join(' ')}] in ${JSON.stringify(env)}:
${stderr}
`);
} else {
resolve(stdout);
}
});
});
}
if (require.main === module) {
const token = process.env['GITHUB_TOKEN']
const cache_dir = process.env['CACHE_DIR'];
const repo = process.argv[2];
process.on('SIGINT', () => {
process.exit(1);
});
new Cleaner(token, repo, cache_dir, os.tmpdir()).run()
.catch(err => {
console.error(err);
process.exit(1);
});
}