|  | 
|  | 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