Mirror Upstream PRs #1
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
  | 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(); |