Skip to content

Commit c6fba68

Browse files
authored
Create pr-mirror.yml
1 parent a81601a commit c6fba68

File tree

1 file changed

+354
-0
lines changed

1 file changed

+354
-0
lines changed

.github/workflows/pr-mirror.yml

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
name: Mirror Upstream PRs
2+
3+
on:
4+
schedule:
5+
- cron: '0 */12 * * *'
6+
workflow_dispatch:
7+
inputs:
8+
hours_back:
9+
description: 'Hours to look back (default 12)'
10+
required: false
11+
default: '12'
12+
close_outdated:
13+
description: 'Close fork PRs when upstream closes (yes/no)'
14+
required: false
15+
default: 'no'
16+
stealth:
17+
description: 'Avoid upstream links and references (yes/no)'
18+
required: false
19+
default: 'yes'
20+
21+
permissions:
22+
contents: write
23+
pull-requests: write
24+
issues: write
25+
26+
concurrency:
27+
group: pr-mirror
28+
cancel-in-progress: false
29+
30+
jobs:
31+
mirror-prs:
32+
runs-on: ubuntu-latest
33+
steps:
34+
- uses: actions/checkout@v4
35+
with:
36+
fetch-depth: 0
37+
38+
- name: Configure git identity
39+
run: |
40+
git config user.name "github-actions[bot]"
41+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
42+
43+
- name: Setup state file
44+
run: |
45+
mkdir -p .github
46+
if [ ! -f .github/pr-mirror-state.json ]; then
47+
echo '{"last_run": null, "mirrored": {}}' > .github/pr-mirror-state.json
48+
fi
49+
cat .github/pr-mirror-state.json
50+
51+
- name: Mirror upstream PRs
52+
uses: actions/github-script@v7
53+
env:
54+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55+
# Optional for private upstream fetches (PAT or App token with repo read):
56+
# UPSTREAM_TOKEN: ${{ secrets.UPSTREAM_TOKEN }}
57+
with:
58+
script: |
59+
const fs = require('fs');
60+
const cp = require('child_process');
61+
62+
function sh(cmd, opts = {}) {
63+
core.info(`$ ${cmd}`);
64+
return cp.execSync(cmd, { stdio: 'inherit', ...opts });
65+
}
66+
function shQuiet(cmd, opts = {}) {
67+
return cp.execSync(cmd, { stdio: 'inherit', ...opts });
68+
}
69+
70+
// Rate limit info
71+
const { data: rateLimit } = await github.rest.rateLimit.get();
72+
core.info(`API rate remaining: ${rateLimit.rate.remaining}/${rateLimit.rate.limit}`);
73+
74+
// Load state
75+
let state = { last_run: null, mirrored: {} };
76+
try { state = JSON.parse(fs.readFileSync('.github/pr-mirror-state.json', 'utf8')); } catch {}
77+
78+
const hoursBack = parseInt(context.payload.inputs?.hours_back || '12');
79+
const closeOutdated = (context.payload.inputs?.close_outdated || 'no') === 'yes';
80+
const stealth = (context.payload.inputs?.stealth || 'yes') === 'yes';
81+
const now = new Date();
82+
const checkFrom = state.last_run ? new Date(state.last_run) : new Date(now.getTime() - hoursBack * 3600 * 1000);
83+
84+
const repoOwner = context.repo.owner;
85+
const repoName = context.repo.repo;
86+
87+
// Auto-detect upstream (parent/source) of this fork
88+
const { data: thisRepo } = await github.rest.repos.get({ owner: repoOwner, repo: repoName });
89+
if (!thisRepo.fork || (!thisRepo.parent && !thisRepo.source)) {
90+
throw new Error('Cannot auto-detect upstream: this repository is not a fork (no parent/source).');
91+
}
92+
const upstreamMeta = thisRepo.source || thisRepo.parent;
93+
const upstreamOwner = upstreamMeta.owner.login;
94+
const upstreamRepo = upstreamMeta.name;
95+
const upstreamPriv = !!upstreamMeta.private;
96+
97+
core.info(`Window: ${checkFrom.toISOString()} → ${now.toISOString()} | close_outdated=${closeOutdated} | stealth=${stealth}`);
98+
core.info(`Detected upstream: ${upstreamOwner}/${upstreamRepo}${upstreamPriv ? ' (private)' : ''}`);
99+
100+
// If private upstream, auth header for git fetch (needs UPSTREAM_TOKEN)
101+
if (upstreamPriv && !process.env.UPSTREAM_TOKEN) {
102+
core.warning('Upstream is private but UPSTREAM_TOKEN is not set; fetch by pull ref will likely fail.');
103+
}
104+
if (process.env.UPSTREAM_TOKEN) {
105+
const header = "AUTHORIZATION: basic " + Buffer.from(`x-access-token:${process.env.UPSTREAM_TOKEN}`).toString('base64');
106+
shQuiet(`git config --global http.https://github.com/.extraheader "${header}"`);
107+
}
108+
109+
// List PRs updated in window
110+
async function listUpdatedPRsPaged() {
111+
const all = [];
112+
let page = 1;
113+
while (true) {
114+
const { data } = await github.rest.pulls.list({
115+
owner: upstreamOwner, repo: upstreamRepo, state: 'open',
116+
sort: 'updated', direction: 'desc', per_page: 100, page
117+
});
118+
if (!data.length) break;
119+
all.push(...data);
120+
const lastUpdated = new Date(data[data.length - 1].updated_at);
121+
if (lastUpdated < checkFrom) break;
122+
page += 1;
123+
}
124+
return all.filter(pr => new Date(pr.updated_at) >= checkFrom && new Date(pr.updated_at) <= now);
125+
}
126+
127+
const prs = await listUpdatedPRsPaged();
128+
core.info(`Upstream PRs updated in window: ${prs.length}`);
129+
130+
const results = { created: [], updated: [], skipped: [], failed: [], closed: [] };
131+
132+
async function ensureLabel(name, color) {
133+
try { await github.rest.issues.getLabel({ owner: repoOwner, repo: repoName, name }); }
134+
catch {
135+
try { await github.rest.issues.createLabel({ owner: repoOwner, repo: repoName, name, color }); }
136+
catch (e) { core.warning(`Could not create label ${name}: ${e.message}`); }
137+
}
138+
}
139+
await ensureLabel('upstream-mirror', '1d76db');
140+
141+
// Body sanitizer (no links/mentions/issue refs)
142+
function sanitizeBody(src) {
143+
if (!src) return '*No description provided*';
144+
let text = src;
145+
text = text.replace(/\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)\b/g, (_, o, r, n) => `${o} / ${r} PR ${n}`);
146+
text = text.replace(/(^|[^A-Za-z0-9_/.-])#(\d+)\b/g, (m, pre, n) => `${pre}PR ${n}`);
147+
text = text.replace(/@([A-Za-z0-9-]+)/g, '@$1');
148+
text = text.replace(/https?:\/\/github\.com\/\S+/gi, (u) => u.replace(/^https:/i, 'hxxps:').replace(/^http:/i, 'hxxp:'));
149+
text = text.replace(/ {2,}/g, ' ');
150+
return text;
151+
}
152+
153+
// Inline sanitizer (for titles)
154+
function sanitizeInline(s) {
155+
if (!s) return '';
156+
let t = s;
157+
t = t.replace(/\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)\b/g, (_, o, r, n) => `${o} / ${r} PR ${n}`);
158+
t = t.replace(/(^|[^A-Za-z0-9_/.-])#(\d+)\b/g, (m, pre, n) => `${pre}PR ${n}`);
159+
t = t.replace(/@([A-Za-z0-9-]+)/g, '@$1');
160+
return t;
161+
}
162+
163+
// Build stealth body
164+
function buildBody(pr, headSha, nowISO, prevSha) {
165+
const safeUser = (u) => `@${u}`; // full-width @
166+
const safeRef = `${upstreamOwner} / ${upstreamRepo} PR ${pr.number}`;
167+
const safeSHA = headSha.substring(0, 7);
168+
const created = new Date(pr.created_at).toISOString().split('T')[0];
169+
const updated = new Date(pr.updated_at).toISOString().split('T')[0];
170+
171+
const headerTbl =
172+
`| Field | Value |\n` +
173+
`| --- | --- |\n` +
174+
`| Upstream | ${safeRef} |\n` +
175+
`| Author | ${safeUser(pr.user.login)} |\n` +
176+
`| Created | ${created} |\n` +
177+
`| Last Updated | ${updated} |\n` +
178+
`| Mirrored At | ${nowISO} |\n` +
179+
`| Commit | \`${safeSHA}\` |\n` +
180+
`| Status | ${pr.draft ? 'Draft' : 'Ready for review'} |\n`;
181+
182+
const desc = sanitizeBody(pr.body);
183+
const maybeUpdated = (prevSha && prevSha !== headSha) ? `\n---\nUpdated with new commits at ${nowISO}\n` : '';
184+
const meta = `<!-- upstream=${upstreamOwner}/${upstreamRepo} PR=${pr.number} sha=${safeSHA} -->`;
185+
return `## Mirrored from upstream\n\n${headerTbl}\n\n### Original Description (sanitized)\n\n${desc}\n${maybeUpdated}\n${meta}`;
186+
}
187+
188+
for (const pr of prs) {
189+
try {
190+
const number = pr.number;
191+
const headSha = pr.head.sha;
192+
const prev = state.mirrored[number];
193+
194+
if (prev && prev.head_sha === headSha) {
195+
results.skipped.push(`PR #${number} - no new commits`);
196+
continue;
197+
}
198+
199+
const branchName = `upstream-pr-${number}`;
200+
const upstreamBaseRef = pr.base.ref;
201+
202+
// Fetch via pull/<n>/head
203+
let fetched = false;
204+
try {
205+
sh(`git fetch --no-tags --depth=1 https://github.com/${upstreamOwner}/${upstreamRepo}.git pull/${number}/head:${branchName}`);
206+
fetched = true;
207+
} catch (e) {
208+
core.warning(`Direct pull ref failed for #${number}: ${e.message}`);
209+
}
210+
211+
// Fallback: clone head repo/branch
212+
if (!fetched) {
213+
const headRepo = pr.head.repo?.clone_url;
214+
const headRef = pr.head.ref;
215+
if (!headRepo || !headRef) {
216+
results.failed.push(`PR #${number} - missing head repo/ref`);
217+
continue;
218+
}
219+
sh(`rm -rf mirrortmp || true`);
220+
sh(`git clone --no-tags --depth=1 --branch ${headRef} ${headRepo} mirrortmp`);
221+
const ghToken = process.env.GITHUB_TOKEN;
222+
if (ghToken) {
223+
const header = "AUTHORIZATION: basic " + Buffer.from(`x-access-token:${ghToken}`).toString('base64');
224+
shQuiet(`git -C mirrortmp config http.https://github.com/.extraheader "${header}"`);
225+
}
226+
sh(`git -C mirrortmp push https://github.com/${repoOwner}/${repoName}.git HEAD:${branchName} --force`);
227+
try { shQuiet(`git -C mirrortmp config --unset-all http.https://github.com/.extraheader`); } catch {}
228+
sh(`rm -rf mirrortmp || true`);
229+
} else {
230+
sh(`git push origin ${branchName} --force`);
231+
}
232+
233+
// Base branch fallback to fork default
234+
let baseRef = upstreamBaseRef;
235+
try {
236+
await github.rest.repos.getBranch({ owner: repoOwner, repo: repoName, branch: baseRef });
237+
} catch {
238+
const forkMeta = await github.rest.repos.get({ owner: repoOwner, repo: repoName });
239+
baseRef = forkMeta.data.default_branch || 'main';
240+
core.warning(`Base '${upstreamBaseRef}' not found in fork. Using '${baseRef}'`);
241+
}
242+
243+
// Existing PR?
244+
const existing = await github.rest.pulls.list({
245+
owner: repoOwner, repo: repoName, state: 'open', head: `${repoOwner}:${branchName}`,
246+
});
247+
248+
const nowISO = now.toISOString();
249+
const body = buildBody(pr, headSha, nowISO, prev?.head_sha);
250+
251+
let forkPRUrl, forkPRNumber;
252+
if (existing.data.length > 0) {
253+
forkPRUrl = existing.data[0].html_url;
254+
forkPRNumber = existing.data[0].number;
255+
if (!prev || prev.head_sha !== headSha) {
256+
await github.rest.pulls.update({ owner: repoOwner, repo: repoName, pull_number: forkPRNumber, body });
257+
results.updated.push(`PR #${number} → ${forkPRUrl} (new commits)`);
258+
} else {
259+
results.skipped.push(`PR #${number} - already mirrored`);
260+
}
261+
} else {
262+
const created = await github.rest.pulls.create({
263+
owner: repoOwner, repo: repoName,
264+
title: `[Upstream PR ${number}] ${sanitizeInline(pr.title)}`, // sanitized title
265+
body, head: branchName, base: baseRef, draft: true
266+
});
267+
forkPRUrl = created.data.html_url;
268+
forkPRNumber = created.data.number;
269+
results.created.push(`PR #${number} → ${forkPRUrl}`);
270+
271+
// Labels
272+
try {
273+
await github.rest.issues.addLabels({
274+
owner: repoOwner, repo: repoName, issue_number: forkPRNumber,
275+
labels: ['upstream-mirror', `upstream-pr-${number}`]
276+
});
277+
} catch {
278+
await ensureLabel(`upstream-pr-${number}`, 'fbca04');
279+
try {
280+
await github.rest.issues.addLabels({
281+
owner: repoOwner, repo: repoName, issue_number: forkPRNumber,
282+
labels: ['upstream-mirror', `upstream-pr-${number}`]
283+
});
284+
} catch {}
285+
}
286+
}
287+
288+
// Update state
289+
state.mirrored[number] = {
290+
head_sha: headSha, branch: branchName, base: baseRef,
291+
fork_pr_url: forkPRUrl, fork_pr_number: forkPRNumber,
292+
last_updated: now.toISOString(),
293+
upstream_title: pr.title, upstream_author: pr.user.login
294+
};
295+
296+
} catch (err) {
297+
results.failed.push(`PR #${pr.number}: ${err.message}`);
298+
}
299+
}
300+
301+
// Close mirrored PRs when upstream closed — without linking upstream
302+
if (closeOutdated) {
303+
const mirroredNumbers = Object.keys(state.mirrored);
304+
for (const num of mirroredNumbers) {
305+
try {
306+
const upstream = await github.rest.pulls.get({
307+
owner: upstreamOwner, repo: upstreamRepo, pull_number: parseInt(num, 10)
308+
});
309+
if (upstream.data.state === 'closed') {
310+
const info = state.mirrored[num];
311+
if (info?.fork_pr_number) {
312+
const forkPR = await github.rest.pulls.get({
313+
owner: repoOwner, repo: repoName, pull_number: info.fork_pr_number
314+
}).catch(() => null);
315+
if (forkPR && forkPR.data.state === 'open') {
316+
const statusTxt = upstream.data.merged ? 'merged' : 'closed';
317+
await github.rest.issues.createComment({
318+
owner: repoOwner, repo: repoName, issue_number: info.fork_pr_number,
319+
body: `Auto‑closing mirror. Upstream PR ${num} is ${statusTxt}.`
320+
});
321+
await github.rest.pulls.update({
322+
owner: repoOwner, repo: repoName, pull_number: info.fork_pr_number, state: 'closed'
323+
});
324+
results.closed.push(`Closed fork PR #${info.fork_pr_number} (upstream ${num} ${statusTxt})`);
325+
}
326+
}
327+
delete state.mirrored[num];
328+
}
329+
} catch (e) {
330+
core.warning(`Could not check upstream PR ${num}: ${e.message}`);
331+
}
332+
}
333+
}
334+
335+
// Save state and summarize
336+
state.last_run = now.toISOString();
337+
fs.writeFileSync('.github/pr-mirror-state.json', JSON.stringify(state, null, 2));
338+
try { sh('git add .github/pr-mirror-state.json'); sh(`git commit -m "Update PR mirror state - ${now.toISOString()}"`); sh('git push'); } catch {}
339+
340+
const lines = [];
341+
if (results.created.length) lines.push(`### Created (${results.created.length})`, ...results.created.map(x => `- ${x}`), '');
342+
if (results.updated.length) lines.push(`### Updated (${results.updated.length})`, ...results.updated.map(x => `- ${x}`), '');
343+
if (results.closed.length) lines.push(`### Closed (${results.closed.length})`, ...results.closed.map(x => `- ${x}`), '');
344+
if (results.failed.length) lines.push(`### Failed (${results.failed.length})`, ...results.failed.map(x => `- ${x}`), '');
345+
if (results.skipped.length) lines.push(`### Skipped (${results.skipped.length})`, ...results.skipped.map(x => `- ${x}`), '');
346+
if (!lines.length) lines.push('No PRs needed processing in this time window.');
347+
348+
await core.summary
349+
.addHeading('Upstream PR Mirror Report')
350+
.addRaw(`Upstream: ${upstreamOwner} / ${upstreamRepo}\n`)
351+
.addRaw(`Time Range: ${checkFrom.toISOString()} → ${now.toISOString()}\n`)
352+
.addRaw(`Rate Limit: ${rateLimit.rate.remaining}/${rateLimit.rate.limit}\n\n`)
353+
.addRaw(lines.join('\n'))
354+
.write();

0 commit comments

Comments
 (0)