Skip to content

Mirror Upstream PRs

Mirror Upstream PRs #1

Workflow file for this run

name: Mirror Upstream PRs
on:
schedule:
- cron: '0 */12 * * *'
workflow_dispatch:
inputs:
hours_back:
description: 'Hours to look back (default 12)'
required: false
default: '12'
close_outdated:
description: 'Close fork PRs when upstream closes (yes/no)'
required: false
default: 'no'
stealth:
description: 'Avoid upstream links and references (yes/no)'
required: false
default: 'yes'
permissions:
contents: write
pull-requests: write
issues: write
concurrency:
group: pr-mirror
cancel-in-progress: false
jobs:
mirror-prs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Setup state file
run: |
mkdir -p .github
if [ ! -f .github/pr-mirror-state.json ]; then
echo '{"last_run": null, "mirrored": {}}' > .github/pr-mirror-state.json
fi
cat .github/pr-mirror-state.json
- name: Mirror upstream PRs
uses: actions/github-script@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Optional for private upstream fetches (PAT or App token with repo read):
# UPSTREAM_TOKEN: ${{ secrets.UPSTREAM_TOKEN }}
with:
script: |
const fs = require('fs');
const cp = require('child_process');
function sh(cmd, opts = {}) {
core.info(`$ ${cmd}`);
return cp.execSync(cmd, { stdio: 'inherit', ...opts });
}
function shQuiet(cmd, opts = {}) {
return cp.execSync(cmd, { stdio: 'inherit', ...opts });
}
// Rate limit info
const { data: rateLimit } = await github.rest.rateLimit.get();
core.info(`API rate remaining: ${rateLimit.rate.remaining}/${rateLimit.rate.limit}`);
// Load state
let state = { last_run: null, mirrored: {} };
try { state = JSON.parse(fs.readFileSync('.github/pr-mirror-state.json', 'utf8')); } catch {}
const hoursBack = parseInt(context.payload.inputs?.hours_back || '12');
const closeOutdated = (context.payload.inputs?.close_outdated || 'no') === 'yes';
const stealth = (context.payload.inputs?.stealth || 'yes') === 'yes';
const now = new Date();
const checkFrom = state.last_run ? new Date(state.last_run) : new Date(now.getTime() - hoursBack * 3600 * 1000);
const repoOwner = context.repo.owner;
const repoName = context.repo.repo;
// Auto-detect upstream (parent/source) of this fork
const { data: thisRepo } = await github.rest.repos.get({ owner: repoOwner, repo: repoName });
if (!thisRepo.fork || (!thisRepo.parent && !thisRepo.source)) {
throw new Error('Cannot auto-detect upstream: this repository is not a fork (no parent/source).');
}
const upstreamMeta = thisRepo.source || thisRepo.parent;
const upstreamOwner = upstreamMeta.owner.login;
const upstreamRepo = upstreamMeta.name;
const upstreamPriv = !!upstreamMeta.private;
core.info(`Window: ${checkFrom.toISOString()} → ${now.toISOString()} | close_outdated=${closeOutdated} | stealth=${stealth}`);
core.info(`Detected upstream: ${upstreamOwner}/${upstreamRepo}${upstreamPriv ? ' (private)' : ''}`);
// If private upstream, auth header for git fetch (needs UPSTREAM_TOKEN)
if (upstreamPriv && !process.env.UPSTREAM_TOKEN) {
core.warning('Upstream is private but UPSTREAM_TOKEN is not set; fetch by pull ref will likely fail.');
}
if (process.env.UPSTREAM_TOKEN) {
const header = "AUTHORIZATION: basic " + Buffer.from(`x-access-token:${process.env.UPSTREAM_TOKEN}`).toString('base64');
shQuiet(`git config --global http.https://github.com/.extraheader "${header}"`);
}
// List PRs updated in window
async function listUpdatedPRsPaged() {
const all = [];
let page = 1;
while (true) {
const { data } = await github.rest.pulls.list({
owner: upstreamOwner, repo: upstreamRepo, state: 'open',
sort: 'updated', direction: 'desc', per_page: 100, page
});
if (!data.length) break;
all.push(...data);
const lastUpdated = new Date(data[data.length - 1].updated_at);
if (lastUpdated < checkFrom) break;
page += 1;
}
return all.filter(pr => new Date(pr.updated_at) >= checkFrom && new Date(pr.updated_at) <= now);
}
const prs = await listUpdatedPRsPaged();
core.info(`Upstream PRs updated in window: ${prs.length}`);
const results = { created: [], updated: [], skipped: [], failed: [], closed: [] };
async function ensureLabel(name, color) {
try { await github.rest.issues.getLabel({ owner: repoOwner, repo: repoName, name }); }
catch {
try { await github.rest.issues.createLabel({ owner: repoOwner, repo: repoName, name, color }); }
catch (e) { core.warning(`Could not create label ${name}: ${e.message}`); }
}
}
await ensureLabel('upstream-mirror', '1d76db');
// Body sanitizer (no links/mentions/issue refs)
function sanitizeBody(src) {
if (!src) return '*No description provided*';
let text = src;
text = text.replace(/\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)\b/g, (_, o, r, n) => `${o} / ${r} PR ${n}`);
text = text.replace(/(^|[^A-Za-z0-9_/.-])#(\d+)\b/g, (m, pre, n) => `${pre}PR ${n}`);
text = text.replace(/@([A-Za-z0-9-]+)/g, '@$1');
text = text.replace(/https?:\/\/github\.com\/\S+/gi, (u) => u.replace(/^https:/i, 'hxxps:').replace(/^http:/i, 'hxxp:'));
text = text.replace(/ {2,}/g, ' ');
return text;
}
// Inline sanitizer (for titles)
function sanitizeInline(s) {
if (!s) return '';
let t = s;
t = t.replace(/\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)\b/g, (_, o, r, n) => `${o} / ${r} PR ${n}`);
t = t.replace(/(^|[^A-Za-z0-9_/.-])#(\d+)\b/g, (m, pre, n) => `${pre}PR ${n}`);
t = t.replace(/@([A-Za-z0-9-]+)/g, '@$1');
return t;
}
// Build stealth body
function buildBody(pr, headSha, nowISO, prevSha) {
const safeUser = (u) => `@${u}`; // full-width @
const safeRef = `${upstreamOwner} / ${upstreamRepo} PR ${pr.number}`;
const safeSHA = headSha.substring(0, 7);
const created = new Date(pr.created_at).toISOString().split('T')[0];
const updated = new Date(pr.updated_at).toISOString().split('T')[0];
const headerTbl =
`| Field | Value |\n` +
`| --- | --- |\n` +
`| Upstream | ${safeRef} |\n` +
`| Author | ${safeUser(pr.user.login)} |\n` +
`| Created | ${created} |\n` +
`| Last Updated | ${updated} |\n` +
`| Mirrored At | ${nowISO} |\n` +
`| Commit | \`${safeSHA}\` |\n` +
`| Status | ${pr.draft ? 'Draft' : 'Ready for review'} |\n`;
const desc = sanitizeBody(pr.body);
const maybeUpdated = (prevSha && prevSha !== headSha) ? `\n---\nUpdated with new commits at ${nowISO}\n` : '';
const meta = `<!-- upstream=${upstreamOwner}/${upstreamRepo} PR=${pr.number} sha=${safeSHA} -->`;
return `## Mirrored from upstream\n\n${headerTbl}\n\n### Original Description (sanitized)\n\n${desc}\n${maybeUpdated}\n${meta}`;
}
for (const pr of prs) {
try {
const number = pr.number;
const headSha = pr.head.sha;
const prev = state.mirrored[number];
if (prev && prev.head_sha === headSha) {
results.skipped.push(`PR #${number} - no new commits`);
continue;
}
const branchName = `upstream-pr-${number}`;
const upstreamBaseRef = pr.base.ref;
// Fetch via pull/<n>/head
let fetched = false;
try {
sh(`git fetch --no-tags --depth=1 https://github.com/${upstreamOwner}/${upstreamRepo}.git pull/${number}/head:${branchName}`);
fetched = true;
} catch (e) {
core.warning(`Direct pull ref failed for #${number}: ${e.message}`);
}
// Fallback: clone head repo/branch
if (!fetched) {
const headRepo = pr.head.repo?.clone_url;
const headRef = pr.head.ref;
if (!headRepo || !headRef) {
results.failed.push(`PR #${number} - missing head repo/ref`);
continue;
}
sh(`rm -rf mirrortmp || true`);
sh(`git clone --no-tags --depth=1 --branch ${headRef} ${headRepo} mirrortmp`);
const ghToken = process.env.GITHUB_TOKEN;
if (ghToken) {
const header = "AUTHORIZATION: basic " + Buffer.from(`x-access-token:${ghToken}`).toString('base64');
shQuiet(`git -C mirrortmp config http.https://github.com/.extraheader "${header}"`);
}
sh(`git -C mirrortmp push https://github.com/${repoOwner}/${repoName}.git HEAD:${branchName} --force`);
try { shQuiet(`git -C mirrortmp config --unset-all http.https://github.com/.extraheader`); } catch {}
sh(`rm -rf mirrortmp || true`);
} else {
sh(`git push origin ${branchName} --force`);
}
// Base branch fallback to fork default
let baseRef = upstreamBaseRef;
try {
await github.rest.repos.getBranch({ owner: repoOwner, repo: repoName, branch: baseRef });
} catch {
const forkMeta = await github.rest.repos.get({ owner: repoOwner, repo: repoName });
baseRef = forkMeta.data.default_branch || 'main';
core.warning(`Base '${upstreamBaseRef}' not found in fork. Using '${baseRef}'`);
}
// Existing PR?
const existing = await github.rest.pulls.list({
owner: repoOwner, repo: repoName, state: 'open', head: `${repoOwner}:${branchName}`,
});
const nowISO = now.toISOString();
const body = buildBody(pr, headSha, nowISO, prev?.head_sha);
let forkPRUrl, forkPRNumber;
if (existing.data.length > 0) {
forkPRUrl = existing.data[0].html_url;
forkPRNumber = existing.data[0].number;
if (!prev || prev.head_sha !== headSha) {
await github.rest.pulls.update({ owner: repoOwner, repo: repoName, pull_number: forkPRNumber, body });
results.updated.push(`PR #${number} → ${forkPRUrl} (new commits)`);
} else {
results.skipped.push(`PR #${number} - already mirrored`);
}
} else {
const created = await github.rest.pulls.create({
owner: repoOwner, repo: repoName,
title: `[Upstream PR ${number}] ${sanitizeInline(pr.title)}`, // sanitized title
body, head: branchName, base: baseRef, draft: true
});
forkPRUrl = created.data.html_url;
forkPRNumber = created.data.number;
results.created.push(`PR #${number} → ${forkPRUrl}`);
// Labels
try {
await github.rest.issues.addLabels({
owner: repoOwner, repo: repoName, issue_number: forkPRNumber,
labels: ['upstream-mirror', `upstream-pr-${number}`]
});
} catch {
await ensureLabel(`upstream-pr-${number}`, 'fbca04');
try {
await github.rest.issues.addLabels({
owner: repoOwner, repo: repoName, issue_number: forkPRNumber,
labels: ['upstream-mirror', `upstream-pr-${number}`]
});
} catch {}
}
}
// Update state
state.mirrored[number] = {
head_sha: headSha, branch: branchName, base: baseRef,
fork_pr_url: forkPRUrl, fork_pr_number: forkPRNumber,
last_updated: now.toISOString(),
upstream_title: pr.title, upstream_author: pr.user.login
};
} catch (err) {
results.failed.push(`PR #${pr.number}: ${err.message}`);
}
}
// Close mirrored PRs when upstream closed — without linking upstream
if (closeOutdated) {
const mirroredNumbers = Object.keys(state.mirrored);
for (const num of mirroredNumbers) {
try {
const upstream = await github.rest.pulls.get({
owner: upstreamOwner, repo: upstreamRepo, pull_number: parseInt(num, 10)
});
if (upstream.data.state === 'closed') {
const info = state.mirrored[num];
if (info?.fork_pr_number) {
const forkPR = await github.rest.pulls.get({
owner: repoOwner, repo: repoName, pull_number: info.fork_pr_number
}).catch(() => null);
if (forkPR && forkPR.data.state === 'open') {
const statusTxt = upstream.data.merged ? 'merged' : 'closed';
await github.rest.issues.createComment({
owner: repoOwner, repo: repoName, issue_number: info.fork_pr_number,
body: `Auto‑closing mirror. Upstream PR ${num} is ${statusTxt}.`
});
await github.rest.pulls.update({
owner: repoOwner, repo: repoName, pull_number: info.fork_pr_number, state: 'closed'
});
results.closed.push(`Closed fork PR #${info.fork_pr_number} (upstream ${num} ${statusTxt})`);
}
}
delete state.mirrored[num];
}
} catch (e) {
core.warning(`Could not check upstream PR ${num}: ${e.message}`);
}
}
}
// Save state and summarize
state.last_run = now.toISOString();
fs.writeFileSync('.github/pr-mirror-state.json', JSON.stringify(state, null, 2));
try { sh('git add .github/pr-mirror-state.json'); sh(`git commit -m "Update PR mirror state - ${now.toISOString()}"`); sh('git push'); } catch {}
const lines = [];
if (results.created.length) lines.push(`### Created (${results.created.length})`, ...results.created.map(x => `- ${x}`), '');
if (results.updated.length) lines.push(`### Updated (${results.updated.length})`, ...results.updated.map(x => `- ${x}`), '');
if (results.closed.length) lines.push(`### Closed (${results.closed.length})`, ...results.closed.map(x => `- ${x}`), '');
if (results.failed.length) lines.push(`### Failed (${results.failed.length})`, ...results.failed.map(x => `- ${x}`), '');
if (results.skipped.length) lines.push(`### Skipped (${results.skipped.length})`, ...results.skipped.map(x => `- ${x}`), '');
if (!lines.length) lines.push('No PRs needed processing in this time window.');
await core.summary
.addHeading('Upstream PR Mirror Report')
.addRaw(`Upstream: ${upstreamOwner} / ${upstreamRepo}\n`)
.addRaw(`Time Range: ${checkFrom.toISOString()} → ${now.toISOString()}\n`)
.addRaw(`Rate Limit: ${rateLimit.rate.remaining}/${rateLimit.rate.limit}\n\n`)
.addRaw(lines.join('\n'))
.write();