mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
304 lines
9.7 KiB
JavaScript
Executable File
304 lines
9.7 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
//
|
|
// checkout-pr.js — prepare a local branch from a fork contribution
|
|
//
|
|
// Usage:
|
|
// node scripts/checkout-pr.js <PR-number>
|
|
// node scripts/checkout-pr.js <PR-URL>
|
|
// node scripts/checkout-pr.js <user>:<branch>
|
|
// node scripts/checkout-pr.js <fork-repo-url> <branch>
|
|
// node scripts/checkout-pr.js git@github.com:<user>/<repo>.git <branch>
|
|
//
|
|
// Examples:
|
|
// node scripts/checkout-pr.js 123
|
|
// node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/<PR-number>
|
|
// node scripts/checkout-pr.js <user>:<branch>
|
|
// node scripts/checkout-pr.js <fork-repo-url> <branch>
|
|
// node scripts/checkout-pr.js git@github.com:<user>/<repo>.git <branch>
|
|
|
|
'use strict';
|
|
|
|
const { execSync } = require('child_process');
|
|
const https = require('https');
|
|
|
|
// Colors (disabled if stdout is not a TTY)
|
|
const isTTY = process.stdout.isTTY;
|
|
const c = {
|
|
red: isTTY ? '\x1b[0;31m' : '',
|
|
green: isTTY ? '\x1b[0;32m' : '',
|
|
yellow: isTTY ? '\x1b[0;33m' : '',
|
|
cyan: isTTY ? '\x1b[0;36m' : '',
|
|
bold: isTTY ? '\x1b[1m' : '',
|
|
reset: isTTY ? '\x1b[0m' : '',
|
|
};
|
|
|
|
function info(msg) { console.log(`${c.cyan}\u2192${c.reset} ${msg}`); }
|
|
function ok(msg) { console.log(`${c.green}\u2713${c.reset} ${msg}`); }
|
|
function warn(msg) { console.log(`${c.yellow}\u26A0${c.reset} ${msg}`); }
|
|
function err(msg) { console.error(`${c.red}\u2717${c.reset} ${msg}`); }
|
|
|
|
function die(msg) {
|
|
err(msg);
|
|
process.exit(1);
|
|
}
|
|
|
|
function usage() {
|
|
console.log(`Usage:
|
|
npm run checkout:pr -- <PR-number>
|
|
npm run checkout:pr -- <PR-URL>
|
|
npm run checkout:pr -- <user>:<branch>
|
|
npm run checkout:pr -- <fork-repo-url> <branch>
|
|
npm run checkout:pr -- git@github.com:<user>/<repo>.git <branch>
|
|
|
|
Prepares a local branch from a fork contribution for testing and review.
|
|
|
|
Examples:
|
|
npm run checkout:pr -- 123
|
|
npm run checkout:pr -- https://github.com/meteor/meteor/pull/<PR-number>
|
|
npm run checkout:pr -- <user>:<branch>
|
|
npm run checkout:pr -- <fork-repo-url> <branch>
|
|
npm run checkout:pr -- git@github.com:<user>/<repo>.git <branch>`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function git(cmd, { silent = false } = {}) {
|
|
try {
|
|
return execSync(`git ${cmd}`, {
|
|
encoding: 'utf8',
|
|
stdio: silent ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'inherit'],
|
|
}).trim();
|
|
} catch (e) {
|
|
if (silent) return null;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function ghCli(args) {
|
|
try {
|
|
return execSync(`gh ${args}`, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function hasCommand(name) {
|
|
try {
|
|
execSync(process.platform === 'win32' ? `where ${name}` : `command -v ${name}`, { stdio: 'pipe' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function httpsGet(url) {
|
|
return new Promise((resolve, reject) => {
|
|
https.get(url, { headers: { 'User-Agent': 'meteor-checkout-pr' } }, (res) => {
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
}
|
|
let data = '';
|
|
res.on('data', (chunk) => { data += chunk; });
|
|
res.on('end', () => resolve(data));
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function extractFromPrUrl(prUrl) {
|
|
const match = prUrl.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
|
|
if (!match) die(`could not parse PR URL: ${prUrl}`);
|
|
const [, repoPath, prNumber] = match;
|
|
|
|
// Try gh CLI first
|
|
if (hasCommand('gh')) {
|
|
const result = ghCli(`pr view "${prUrl}" --json headRepositoryOwner,headRefName`);
|
|
if (result) {
|
|
try {
|
|
const data = JSON.parse(result);
|
|
const owner = data.headRepositoryOwner?.login;
|
|
const branch = data.headRefName;
|
|
if (owner && branch) return { owner, branch };
|
|
} catch { /* fall through */ }
|
|
}
|
|
}
|
|
|
|
// Fall back to GitHub REST API
|
|
const apiUrl = `https://api.github.com/repos/${repoPath}/pulls/${prNumber}`;
|
|
let body;
|
|
try {
|
|
body = await httpsGet(apiUrl);
|
|
} catch (e) {
|
|
die(`could not fetch PR data from ${apiUrl} (${e.message})`);
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(body);
|
|
const owner = data.head?.user?.login;
|
|
const branch = data.head?.ref;
|
|
if (owner && branch) return { owner, branch };
|
|
} catch { /* fall through */ }
|
|
|
|
die(`could not extract fork owner/branch from PR #${prNumber}`);
|
|
}
|
|
|
|
function getRepoPathFromOrigin() {
|
|
const originUrl = git('remote get-url origin', { silent: true });
|
|
if (!originUrl) return null;
|
|
// Match HTTPS: https://github.com/owner/repo(.git)
|
|
const httpsMatch = originUrl.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
if (httpsMatch) return httpsMatch[1];
|
|
// Match SSH: git@github.com:owner/repo(.git)
|
|
const sshMatch = originUrl.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
if (sshMatch) return sshMatch[1];
|
|
return null;
|
|
}
|
|
|
|
function extractOwnerFromUrl(url) {
|
|
const match = url.match(/github\.com[:/]([^/]+)\//);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
function normalizeUrl(url) {
|
|
return url
|
|
.replace(/\.git$/, '')
|
|
.replace(/\/$/, '')
|
|
.replace(/^https?:\/\//, '')
|
|
.replace(/^git@github\.com:/, 'github.com/');
|
|
}
|
|
|
|
function buildForkUrl(owner) {
|
|
// Match origin's protocol (SSH vs HTTPS)
|
|
const originUrl = git('remote get-url origin', { silent: true }) || '';
|
|
if (originUrl.startsWith('git@')) {
|
|
return `git@github.com:${owner}/meteor.git`;
|
|
}
|
|
return `https://github.com/${owner}/meteor.git`;
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
// Ensure we're inside a git repo
|
|
if (!git('rev-parse --is-inside-work-tree', { silent: true })) {
|
|
die('not inside a git repository');
|
|
}
|
|
|
|
let forkOwner, forkBranch, forkRepoUrl;
|
|
|
|
if (args.length === 1) {
|
|
const arg = args[0];
|
|
const prNumberMatch = arg.match(/^\d+$/);
|
|
const prMatch = arg.match(/^https?:\/\/github\.com\/.*\/pull\/\d+/);
|
|
const sshMatch = arg.match(/^git@[^:]+:/);
|
|
const httpsRepoMatch = arg.match(/^https?:\/\/.*\.git$/);
|
|
// user:branch — must not start with git@ (SSH) or contain / before : (URLs)
|
|
const shortMatch = !sshMatch && arg.match(/^([^/:]+):(.+)$/);
|
|
|
|
if (prNumberMatch) {
|
|
const repoPath = getRepoPathFromOrigin();
|
|
if (!repoPath) die('could not determine repository from origin remote');
|
|
const prUrl = `https://github.com/${repoPath}/pull/${arg}`;
|
|
info(`resolved PR #${arg} → ${c.bold}${prUrl}${c.reset}`);
|
|
const result = await extractFromPrUrl(prUrl);
|
|
forkOwner = result.owner;
|
|
forkBranch = result.branch;
|
|
forkRepoUrl = buildForkUrl(forkOwner);
|
|
} else if (prMatch) {
|
|
const result = await extractFromPrUrl(arg);
|
|
forkOwner = result.owner;
|
|
forkBranch = result.branch;
|
|
forkRepoUrl = buildForkUrl(forkOwner);
|
|
} else if (sshMatch || httpsRepoMatch) {
|
|
die(`repo URL requires a branch argument: node scripts/checkout-pr.js ${arg} <branch>`);
|
|
} else if (shortMatch) {
|
|
forkOwner = shortMatch[1];
|
|
forkBranch = shortMatch[2];
|
|
forkRepoUrl = buildForkUrl(forkOwner);
|
|
} else {
|
|
err(`unrecognized format: ${arg}`);
|
|
console.error('');
|
|
return usage();
|
|
}
|
|
} else if (args.length === 2) {
|
|
forkRepoUrl = args[0];
|
|
forkBranch = args[1];
|
|
forkOwner = extractOwnerFromUrl(forkRepoUrl);
|
|
if (!forkOwner) die(`could not extract owner from URL: ${forkRepoUrl}`);
|
|
} else {
|
|
return usage();
|
|
}
|
|
|
|
const previousBranch = git('symbolic-ref --short HEAD', { silent: true })
|
|
|| git('rev-parse --short HEAD', { silent: true })
|
|
|| 'HEAD';
|
|
|
|
// Detect if the PR is from the upstream repo (not a fork)
|
|
let remoteName = '';
|
|
let isUpstream = false;
|
|
const originUrl = git('remote get-url origin', { silent: true });
|
|
if (originUrl) {
|
|
const normFork = normalizeUrl(forkRepoUrl);
|
|
const normOrigin = normalizeUrl(originUrl);
|
|
if (normFork === normOrigin) {
|
|
remoteName = 'origin';
|
|
isUpstream = true;
|
|
}
|
|
}
|
|
|
|
let localBranch;
|
|
if (isUpstream) {
|
|
localBranch = forkBranch;
|
|
} else {
|
|
remoteName = forkOwner;
|
|
localBranch = `fork/${forkOwner}/${forkBranch}`;
|
|
}
|
|
|
|
console.log(`${c.bold}--- checkout-pr ---${c.reset}`);
|
|
info(`owner: ${c.bold}${forkOwner}${c.reset}`);
|
|
info(`branch: ${c.bold}${forkBranch}${c.reset}`);
|
|
info(`repo: ${c.bold}${forkRepoUrl}${c.reset}`);
|
|
if (isUpstream) {
|
|
info(`upstream: yes (using remote '${c.bold}${remoteName}${c.reset}')`);
|
|
}
|
|
info(`local branch: ${c.bold}${localBranch}${c.reset}`);
|
|
console.log('');
|
|
|
|
// Add remote if needed (skip for upstream PRs)
|
|
if (!isUpstream) {
|
|
const existingUrl = git(`remote get-url "${remoteName}"`, { silent: true });
|
|
if (existingUrl) {
|
|
warn(`remote '${remoteName}' already exists, reusing it`);
|
|
} else {
|
|
info(`adding remote '${remoteName}' \u2192 ${forkRepoUrl}`);
|
|
git(`remote add "${remoteName}" "${forkRepoUrl}"`);
|
|
ok(`remote '${remoteName}' added`);
|
|
}
|
|
}
|
|
|
|
// Fetch the branch
|
|
info(`fetching '${forkBranch}' from '${remoteName}'...`);
|
|
const fetchResult = git(`fetch "${remoteName}" "${forkBranch}"`, { silent: true });
|
|
if (fetchResult === null) {
|
|
die(`failed to fetch branch '${forkBranch}' from '${remoteName}' \u2014 check that the fork and branch exist`);
|
|
}
|
|
ok(`fetched latest from '${remoteName}'`);
|
|
|
|
// Create or switch to local branch
|
|
const branchExists = git(`show-ref --verify "refs/heads/${localBranch}"`, { silent: true });
|
|
if (branchExists) {
|
|
warn(`branch '${localBranch}' already exists, switching and updating...`);
|
|
git(`checkout "${localBranch}"`);
|
|
git(`reset --hard "refs/remotes/${remoteName}/${forkBranch}"`);
|
|
} else {
|
|
info(`creating branch '${localBranch}'...`);
|
|
git(`checkout -b "${localBranch}" "refs/remotes/${remoteName}/${forkBranch}"`);
|
|
}
|
|
|
|
console.log('');
|
|
ok(`ready on branch: ${c.bold}${localBranch}${c.reset}`);
|
|
info(`to switch back: ${c.bold}git checkout ${previousBranch}${c.reset}`);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
die(e.message);
|
|
});
|