Mirror Upstream PRs #5
  
    
      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: '*/30 * * * *' # every 30 minutes | |
| workflow_dispatch: | |
| inputs: | |
| hours_back: | |
| description: 'Hours to look back (default 1)' | |
| required: false | |
| default: '1' | |
| 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' | |
| open_issue_on_fail: | |
| description: 'Open a failure issue when mirroring fails (yes/no)' | |
| required: false | |
| default: 'no' | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| concurrency: | |
| group: pr-mirror-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| mirror-prs: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Configure git for speed and identity | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git config --global fetch.parallel 8 | |
| git config --global advice.detachedHead false | |
| env: | |
| GIT_LFS_SKIP_SMUDGE: "1" | |
| - name: Ensure 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 | |
| echo "Current state:" | |
| 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 reads (PAT or App token with repo read): | |
| # UPSTREAM_TOKEN: ${{ secrets.UPSTREAM_TOKEN }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const cp = require('child_process'); | |
| // --- helpers --- | |
| function sh(cmd, opts = {}) { | |
| core.info(`$ ${cmd}`); | |
| return cp.execSync(cmd, { stdio: 'inherit', ...opts }); | |
| } | |
| function shQuiet(cmd, opts = {}) { // do not echo command (for secrets) | |
| return cp.execSync(cmd, { stdio: 'inherit', ...opts }); | |
| } | |
| // Retry helper for network operations | |
| async function withRetry(fn, retries = 3) { | |
| for (let i = 0; i < retries; i++) { | |
| try { | |
| return await fn(); | |
| } catch (e) { | |
| if (i === retries - 1) throw e; | |
| const delay = 1000 * Math.pow(2, i); | |
| core.warning(`Attempt ${i + 1} failed, retrying in ${delay}ms: ${e.message}`); | |
| await new Promise(r => setTimeout(r, delay)); | |
| } | |
| } | |
| } | |
| // Ensure label exists | |
| 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 }); | |
| core.info(`Created label: ${name}`); | |
| } catch (e) { | |
| core.warning(`Could not create label ${name}: ${e.message}`); | |
| } | |
| } | |
| } | |
| // Mask secrets early (defense in depth) | |
| if (process.env.UPSTREAM_TOKEN) core.setSecret(process.env.UPSTREAM_TOKEN); | |
| if (process.env.GITHUB_TOKEN) core.setSecret(process.env.GITHUB_TOKEN); | |
| // Rate limit info | |
| const { data: rateLimit } = await github.rest.rateLimit.get(); | |
| core.info(`API rate limit: ${rateLimit.rate.remaining}/${rateLimit.rate.limit}`); | |
| if (rateLimit.rate.remaining < 100) { | |
| core.warning(`Low API rate limit: ${rateLimit.rate.remaining} remaining`); | |
| } | |
| // Load state | |
| let state = { last_run: null, mirrored: {} }; | |
| try { | |
| state = JSON.parse(fs.readFileSync('.github/pr-mirror-state.json', 'utf8')); | |
| } catch { | |
| core.info('Starting with fresh state'); | |
| } | |
| // Parse inputs | |
| const hoursBack = parseInt(context.payload.inputs?.hours_back || '1', 10); | |
| const closeOutdated = (context.payload.inputs?.close_outdated || 'no') === 'yes'; | |
| const stealth = (context.payload.inputs?.stealth || 'yes') === 'yes'; | |
| const openIssueOnFail = (context.payload.inputs?.open_issue_on_fail || 'no') === 'yes'; | |
| const MAX_TO_PROCESS = 60; | |
| 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 (source preferred, else parent) | |
| const { data: thisRepo } = await github.rest.repos.get({ owner: repoOwner, repo: repoName }); | |
| if (!thisRepo.fork || (!thisRepo.parent && !thisRepo.source)) { | |
| core.setFailed('This repository is not a fork (no parent/source) — cannot auto-detect upstream.'); | |
| return; | |
| } | |
| const upstreamMeta = thisRepo.source || thisRepo.parent; | |
| const upstreamOwner = upstreamMeta.owner.login; | |
| const upstreamRepo = upstreamMeta.name; | |
| const upstreamPriv = !!upstreamMeta.private; | |
| core.info(`Configuration:`); | |
| core.info(` Time window: ${checkFrom.toISOString()} → ${now.toISOString()}`); | |
| core.info(` Upstream: ${upstreamOwner}/${upstreamRepo}${upstreamPriv ? ' (private)' : ''}`); | |
| core.info(` Options: stealth=${stealth}, close_outdated=${closeOutdated}, open_issue_on_fail=${openIssueOnFail}`); | |
| // If private upstream, set git header (needs UPSTREAM_TOKEN) | |
| if (upstreamPriv && !process.env.UPSTREAM_TOKEN) { | |
| core.warning('Upstream is private but UPSTREAM_TOKEN is not set; pull refs may 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 withRetry(() => | |
| 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 => { | |
| const updatedAt = new Date(pr.updated_at); | |
| return updatedAt >= checkFrom && updatedAt <= now; | |
| }); | |
| } | |
| // Sanitizers for stealth mode | |
| function sanitizeBody(src) { | |
| if (!src) return '*No description provided*'; | |
| let text = src; | |
| // owner/repo#123 → owner / repo PR 123 | |
| text = text.replace(/\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)\b/g, (_, o, r, n) => `${o} / ${r} PR ${n}`); | |
| // #123 → PR 123 (but preserve markdown headers) | |
| text = text.replace(/(^|[^#\n])#(\d+)\b/g, (m, pre, n) => `${pre}PR ${n}`); | |
| // @user → @user (full-width at symbol) | |
| text = text.replace(/@([A-Za-z0-9-]+)/g, '@$1'); | |
| // GitHub URLs → hxxps | |
| text = text.replace(/https?:\/\/github\.com\/\S+/gi, (u) => u.replace(/^https:/i, 'hxxps:').replace(/^http:/i, 'hxxp:')); | |
| // Collapse excessive spaces | |
| text = text.replace(/ {2,}/g, ' '); | |
| return text; | |
| } | |
| 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(/(^|[^#])#(\d+)\b/g, (m, pre, n) => `${pre}PR ${n}`); | |
| t = t.replace(/@([A-Za-z0-9-]+)/g, '@$1'); | |
| return t; | |
| } | |
| function buildBody(pr, headSha, nowISO, prevSha) { | |
| const safeUser = stealth ? `@${pr.user.login}` : `@${pr.user.login}`; | |
| const safeRef = stealth ? `${upstreamOwner} / ${upstreamRepo} PR ${pr.number}` : `${upstreamOwner}/${upstreamRepo}#${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} |\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 = stealth ? sanitizeBody(pr.body) : (pr.body || '*No description provided*'); | |
| const maybeUpdated = (prevSha && prevSha !== headSha) ? `\n---\n⚠️ Updated 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${stealth ? ' (sanitized)' : ''}\n\n${desc}\n${maybeUpdated}\n${meta}`; | |
| } | |
| // Results tracking | |
| const results = { created: [], updated: [], skipped: [], failed: [], closed: [] }; | |
| try { | |
| // Create labels upfront | |
| await ensureLabel('upstream-mirror', '0e8a16'); | |
| // Fetch PRs | |
| let prs = await listUpdatedPRsPaged(); | |
| if (prs.length > MAX_TO_PROCESS) { | |
| core.warning(`Limiting to first ${MAX_TO_PROCESS} PRs this run (found ${prs.length}).`); | |
| prs = prs.slice(0, MAX_TO_PROCESS); | |
| } | |
| core.info(`Processing ${prs.length} PR(s) from upstream.`); | |
| // Process each PR | |
| for (const pr of prs) { | |
| try { | |
| const number = pr.number; | |
| const headSha = pr.head.sha; | |
| const prev = state.mirrored[number]; | |
| // Skip if unchanged | |
| 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; | |
| core.info(`Processing PR #${number}: ${pr.title}`); | |
| // Try fetch via pull/<n>/head | |
| let fetched = false; | |
| try { | |
| core.startGroup(`Fetch PR #${number}`); | |
| 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}`); | |
| } finally { | |
| core.endGroup(); | |
| } | |
| // Fallback: clone head repo/branch | |
| if (!fetched) { | |
| const headRepo = pr.head.repo?.clone_url; | |
| const headRef = pr.head.ref; | |
| if (!headRepo) { | |
| results.failed.push(`PR #${number} - source fork deleted or private`); | |
| core.warning(`PR #${number}: The source fork has been deleted or made private`); | |
| continue; | |
| } | |
| if (!headRef) { | |
| results.failed.push(`PR #${number} - missing head ref`); | |
| continue; | |
| } | |
| core.startGroup(`Fallback clone PR #${number}`); | |
| try { | |
| sh(`rm -rf mirrortmp || true`); | |
| sh(`git clone --no-tags --filter=blob:none --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 --no-verify`); | |
| try { | |
| shQuiet(`git -C mirrortmp config --unset-all http.https://github.com/.extraheader`); | |
| } catch {} | |
| } finally { | |
| sh(`rm -rf mirrortmp || true`); | |
| core.endGroup(); | |
| } | |
| } else { | |
| sh(`git push --no-verify origin ${branchName} --force`); | |
| } | |
| // Determine base branch in fork | |
| 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}'`); | |
| } | |
| // Check for 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) { | |
| // Update existing PR | |
| 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 { | |
| // Create new PR | |
| const title = stealth ? | |
| `[Upstream PR ${number}] ${sanitizeInline(pr.title)}` : | |
| `[Upstream PR #${number}] ${pr.title}`; | |
| const created = await withRetry(() => | |
| github.rest.pulls.create({ | |
| owner: repoOwner, | |
| repo: repoName, | |
| title, | |
| body, | |
| head: branchName, | |
| base: baseRef, | |
| draft: true | |
| }) | |
| ); | |
| forkPRUrl = created.data.html_url; | |
| forkPRNumber = created.data.number; | |
| results.created.push(`PR #${number} → ${forkPRUrl}`); | |
| // Add 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 (e) { | |
| core.warning(`Could not add labels to PR #${forkPRNumber}: ${e.message}`); | |
| } | |
| } | |
| } | |
| // 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) { | |
| core.error(`Failed to mirror PR #${pr.number}: ${err.message}`); | |
| results.failed.push(`PR #${pr.number}: ${err.message}`); | |
| } | |
| } | |
| // Close mirrored PRs whose upstream is closed | |
| if (closeOutdated) { | |
| core.info('Checking for closed upstream PRs...'); | |
| const mirroredNumbers = Object.keys(state.mirrored); | |
| for (const num of mirroredNumbers) { | |
| try { | |
| const upstream = await withRetry(() => | |
| 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'; | |
| const commentBody = stealth ? | |
| `🔒 Auto-closing mirror. Upstream PR ${num} is ${statusTxt}.` : | |
| `🔒 Auto-closing: Upstream ${upstreamOwner}/${upstreamRepo}#${num} is ${statusTxt}.`; | |
| await github.rest.issues.createComment({ | |
| owner: repoOwner, | |
| repo: repoName, | |
| issue_number: info.fork_pr_number, | |
| body: commentBody | |
| }); | |
| 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}`); | |
| } | |
| } | |
| } | |
| } catch (outerErr) { | |
| results.failed.push(`Fatal error: ${outerErr.message}`); | |
| core.setFailed(outerErr.message); | |
| } finally { | |
| // Save state | |
| 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 - ${state.last_run}"`); | |
| sh('git push'); | |
| } catch { | |
| core.info('No state changes to commit'); | |
| } | |
| // Clean up git config | |
| try { | |
| shQuiet('git config --global --unset-all http.https://github.com/.extraheader'); | |
| } catch {} | |
| // Build summary | |
| 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(`**API Rate Limit:** ${rateLimit.rate.remaining}/${rateLimit.rate.limit}\n\n`) | |
| .addRaw(lines.join('\n')) | |
| .write(); | |
| // Optional failure issue | |
| if (openIssueOnFail && results.failed.length) { | |
| await github.rest.issues.create({ | |
| owner: repoOwner, | |
| repo: repoName, | |
| title: `PR Mirror Failures - ${now.toISOString().split('T')[0]}`, | |
| body: `## ⚠️ Mirror Failures\n\n${results.failed.map(f => `- ${f}`).join('\n')}\n\n[View workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`, | |
| labels: ['mirror-failure', 'upstream-mirror'] | |
| }).catch(e => core.warning(`Could not create issue: ${e.message}`)); | |
| } | |
| const totalProcessed = results.created.length + results.updated.length + results.closed.length; | |
| core.info(`✅ Mirroring complete. Processed ${totalProcessed} PRs, skipped ${results.skipped.length}, failed ${results.failed.length}`); | |
| } |