From 93e4d86ff839c7ddccee2fe8551f43b64468a526 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Thu, 14 May 2026 18:00:34 +0200 Subject: [PATCH] feat: automate upstream patch sync --- .../workflows/comment-upstream-patches.yml | 36 + .github/workflows/sync-upstream-patches.yml | 105 +++ .gitignore | 1 + README.md | 44 +- package.json | 4 +- scripts/capacitor-patch/upstream-sync.mjs | 671 ++++++++++++++++++ scripts/comment-upstream-patches.mjs | 73 ++ scripts/sync-upstream-patches.mjs | 100 +++ scripts/test-upstream-sync.mjs | 101 +++ 9 files changed, 1124 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/comment-upstream-patches.yml create mode 100644 .github/workflows/sync-upstream-patches.yml create mode 100644 scripts/capacitor-patch/upstream-sync.mjs create mode 100644 scripts/comment-upstream-patches.mjs create mode 100644 scripts/sync-upstream-patches.mjs create mode 100644 scripts/test-upstream-sync.mjs diff --git a/.github/workflows/comment-upstream-patches.yml b/.github/workflows/comment-upstream-patches.yml new file mode 100644 index 0000000..694de57 --- /dev/null +++ b/.github/workflows/comment-upstream-patches.yml @@ -0,0 +1,36 @@ +name: Comment upstream quick patches + +on: + pull_request: + branches: [main] + types: [closed] + +permissions: + contents: read + +jobs: + comment: + if: ${{ github.event.pull_request.merged == true }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Fetch comparison commits + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + git fetch origin "$BASE_SHA" "$HEAD_SHA" + + - name: Comment on upstream Capacitor PRs + env: + PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: bun run comment:upstream-patches -- --base "$BASE_SHA" --head "$HEAD_SHA" diff --git a/.github/workflows/sync-upstream-patches.yml b/.github/workflows/sync-upstream-patches.yml new file mode 100644 index 0000000..0b11ee2 --- /dev/null +++ b/.github/workflows/sync-upstream-patches.yml @@ -0,0 +1,105 @@ +name: Sync upstream Capacitor patches + +on: + workflow_dispatch: + inputs: + require_checks: + description: Only generate patches from Capacitor+ branches with passing checks + required: true + type: boolean + default: true + refresh_existing: + description: Regenerate patch files for catalog entries that already exist + required: true + type: boolean + default: false + max_build_prs: + description: Maximum PR branches that may run compiled artifact builds in one sync + required: true + default: '3' + schedule: + - cron: '17 */6 * * *' + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-upstream-capacitor-patches + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out capacitor-patch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun i + + - name: Check out Capacitor+ + uses: actions/checkout@v6 + with: + repository: Cap-go/capacitor-plus + ref: plus + path: .tmp/capacitor-plus + fetch-depth: 0 + token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} + + - name: Fetch upstream sync branches + run: | + git -C .tmp/capacitor-plus fetch origin \ + '+refs/heads/plus:refs/remotes/origin/plus' \ + '+refs/heads/sync/upstream-pr-*:refs/remotes/origin/sync/upstream-pr-*' + + - name: Generate patch catalog updates + env: + GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} + REQUIRE_CHECKS: ${{ github.event.inputs.require_checks || 'true' }} + REFRESH_EXISTING: ${{ github.event.inputs.refresh_existing || 'false' }} + MAX_BUILD_PRS: ${{ github.event.inputs.max_build_prs || '3' }} + run: | + args=( + --capacitor-plus-dir .tmp/capacitor-plus + --remote origin + --base-ref origin/plus + --max-build-prs "$MAX_BUILD_PRS" + ) + + if [[ "$REQUIRE_CHECKS" == "true" ]]; then + args+=(--require-checks) + fi + + if [[ "$REFRESH_EXISTING" == "true" ]]; then + args+=(--refresh-existing) + fi + + bun run sync:patches -- "${args[@]}" + + - name: Format generated catalog + run: bun run prettier -- --write patches/catalog.json + + - name: Verify + run: bun run verify + + - name: Open patch sync pull request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} + branch: chore/sync-upstream-capacitor-patches + delete-branch: true + commit-message: 'chore: sync upstream Capacitor patches' + title: 'chore: sync upstream Capacitor patches' + body: | + Generated by the recurring Capacitor+ upstream patch sync. + + This PR adds or refreshes patch files and catalog entries from external `ionic-team/capacitor` PRs mirrored by `Cap-go/capacitor-plus` `sync/upstream-pr-*` branches. + labels: | + automated + patches diff --git a/.gitignore b/.gitignore index 83b79fa..3ad5a3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # node files dist node_modules +.tmp # iOS files Pods diff --git a/README.md b/README.md index 178fe01..4145aea 100644 --- a/README.md +++ b/README.md @@ -143,20 +143,44 @@ The bundled catalog tracks external fix PRs mirrored by Capacitor+ auto-sync bra Run `capgo-capacitor-patch list --all` to see the shipped catalog. Each entry includes the original upstream Capacitor PR URL, the Capacitor+ sync branch, target package, supported version range, and patch file. -## Future Automation +## Recurring Patch Automation -The long-term goal is to make this repository the fast path for Capacitor fixes that are waiting upstream. +This repository is the fast path for Capacitor fixes that are waiting upstream. -For every external PR opened against `ionic-team/capacitor`, the automation should: +The `Sync upstream Capacitor patches` workflow runs every 6 hours and can also be started manually from GitHub Actions. It: -1. Detect whether the PR is a fix that changes shipped Capacitor code. -2. Wait until the upstream PR, or the matching Capacitor+ `sync/upstream-pr-*` branch, passes its test suite. -3. Generate package-ready patch files in this repository. -4. Open or update a pull request here with the new `patches/catalog.json` entries and patch files. -5. Run this repository's tests against supported Capacitor versions. -6. Comment on the original upstream PR with the quick-patch ID and install snippet once the patch package PR is ready. +1. Checks out this repository and `Cap-go/capacitor-plus`. +2. Fetches Capacitor+ `sync/upstream-pr-*` branches. +3. Reads the matching `ionic-team/capacitor` PR metadata. +4. Skips PRs from Capacitor team members and collaborators. +5. Skips branches whose Capacitor+ checks are not passing. +6. Generates package-ready patch files and `patches/catalog.json` entries. +7. Runs this repository's verification. +8. Opens or updates a pull request with the generated changes. -The upstream PR comment should only be posted when the patch applies cleanly and this repository's checks pass. A good comment looks like: +The generator handles direct Android and iOS package source changes. It can also build package artifacts for `@capacitor/core`, `@capacitor/cli`, and native bridge asset patches when an upstream PR changes TypeScript source that users do not receive directly in `node_modules`. + +Manual run: + +```bash +bun run sync:patches -- \ + --capacitor-plus-dir ../capacitor-plus \ + --remote capgo \ + --base-ref capgo/plus \ + --require-checks +``` + +Useful options: + +- `--pr ` only processes a specific upstream PR branch. +- `--refresh-existing` regenerates patches for entries that already exist. +- `--no-require-checks` allows local dry-runs before Capacitor+ CI finishes. +- `--max-build-prs ` limits expensive compiled artifact generation. +- `--dry-run` reports what would be generated without writing files. + +After a generated patch PR is merged, the `Comment upstream quick patches` workflow comments on the original upstream Capacitor PR when `PERSONAL_ACCESS_TOKEN` is configured with permission to comment there. + +The upstream PR comment is only posted after the patch entry lands in this repository. A good comment looks like: ````md This fix is available as a quick patch through `@capgo/capacitor-patch`. diff --git a/package.json b/package.json index d9fddc0..2686638 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,9 @@ "verify": "bun run verify:web", "verify:web": "bun run build && bun run test:patch", "test": "bun run test:patch", - "test:patch": "node --test scripts/test-capacitor-patch.mjs", + "test:patch": "node --test scripts/test-capacitor-patch.mjs scripts/test-upstream-sync.mjs", + "sync:patches": "node scripts/sync-upstream-patches.mjs", + "comment:upstream-patches": "node scripts/comment-upstream-patches.mjs", "lint": "bun run eslint && bun run prettier -- --check", "fmt": "bun run eslint -- --fix && bun run prettier -- --write", "eslint": "eslint . --ext .ts", diff --git a/scripts/capacitor-patch/upstream-sync.mjs b/scripts/capacitor-patch/upstream-sync.mjs new file mode 100644 index 0000000..1565b11 --- /dev/null +++ b/scripts/capacitor-patch/upstream-sync.mjs @@ -0,0 +1,671 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const INTERNAL_AUTHOR_ASSOCIATIONS = new Set(['COLLABORATOR', 'MEMBER', 'OWNER']); +const SUCCESSFUL_CHECK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']); +const FAILED_CHECK_CONCLUSIONS = new Set(['action_required', 'cancelled', 'failure', 'startup_failure', 'timed_out']); + +export const PACKAGE_TARGETS = [ + { + key: 'android', + packageName: '@capacitor/android', + root: 'android', + suffix: 'android', + titleSuffix: 'android', + versionRange: '>=8.0.0 <9.0.0', + direct: true, + accepts: (file) => file.startsWith('android/') && isShippedPackageFile(file, 'android'), + }, + { + key: 'ios', + packageName: '@capacitor/ios', + root: 'ios', + suffix: 'ios', + titleSuffix: 'ios', + versionRange: '>=8.0.0 <9.0.0', + direct: true, + accepts: (file) => file.startsWith('ios/') && isShippedPackageFile(file, 'ios'), + }, +]; + +export const COMPILED_TARGETS = [ + { + key: 'core', + packageName: '@capacitor/core', + root: 'core', + suffix: 'core', + titleSuffix: 'core', + versionRange: '>=8.0.0 <9.0.0', + build: 'core', + generatedFiles: ['dist/index.js', 'dist/index.cjs.js'], + triggers: (file) => file.startsWith('core/src/') && isSourceRuntimeFile(file), + }, + { + key: 'cli', + packageName: '@capacitor/cli', + root: 'cli', + suffix: 'cli', + titleSuffix: 'cli', + versionRange: '>=8.0.0 <9.0.0', + build: 'cli', + generatedFiles: ['dist'], + triggers: (file) => file.startsWith('cli/src/') && isSourceRuntimeFile(file), + }, + { + key: 'android-native-bridge', + packageName: '@capacitor/android', + root: 'android', + suffix: 'android-native-bridge', + titleSuffix: 'android-native-bridge', + versionRange: '>=8.0.0 <9.0.0', + build: 'nativebridge', + generatedFiles: ['capacitor/src/main/assets/native-bridge.js'], + triggers: (file) => file === 'core/native-bridge.ts', + }, + { + key: 'ios-native-bridge', + packageName: '@capacitor/ios', + root: 'ios', + suffix: 'ios-native-bridge', + titleSuffix: 'ios-native-bridge', + versionRange: '>=8.0.0 <9.0.0', + build: 'nativebridge', + generatedFiles: ['Capacitor/Capacitor/assets/native-bridge.js'], + triggers: (file) => file === 'core/native-bridge.ts', + }, +]; + +export function parseSyncBranchNumber(branchName) { + const match = /(?:^|\/)sync\/upstream-pr-(\d+)$/.exec(branchName); + return match ? Number(match[1]) : null; +} + +export function sortCatalogEntries(entries) { + return [...entries].sort((a, b) => { + const prA = getEntryPullRequestNumber(a); + const prB = getEntryPullRequestNumber(b); + if (prA !== prB) { + return prA - prB; + } + return String(a.id).localeCompare(String(b.id)); + }); +} + +export function getEntryPullRequestNumber(entry) { + const fromSource = /\/pull\/(\d+)/.exec(entry?.source?.upstreamPullRequest ?? '')?.[1]; + const fromId = /^upstream-pr-(\d+)/.exec(entry?.id ?? '')?.[1]; + return Number(fromSource ?? fromId ?? Number.MAX_SAFE_INTEGER); +} + +export function groupPatchTargets(changedFiles) { + const directTargets = PACKAGE_TARGETS.filter((target) => changedFiles.some((file) => target.accepts(file))); + const compiledTargets = COMPILED_TARGETS.filter((target) => changedFiles.some((file) => target.triggers(file))); + return { + directTargets, + compiledTargets, + allTargets: [...directTargets, ...compiledTargets], + }; +} + +export function createCatalogEntry({ pr, target, patchFile, upstreamStatus, branchUrl }) { + return { + id: `upstream-pr-${pr.number}-${target.suffix}`, + title: `${pr.title} (${target.titleSuffix})`, + recommended: false, + phase: 'package', + target: { + type: 'package', + packageName: target.packageName, + versionRange: target.versionRange, + }, + source: { + upstreamPullRequest: `https://github.com/ionic-team/capacitor/pull/${pr.number}`, + capacitorPlusBranch: branchUrl, + author: pr.author, + authorAssociation: isExternalAuthor(pr.authorAssociation) ? 'external' : 'internal', + }, + upstream: upstreamStatus, + patchFile, + }; +} + +export function createUpstreamStatus(pr) { + const mergedAt = pr.mergedAt ?? null; + if (mergedAt) { + return { + state: pr.state, + mergedAt, + status: 'merged-upstream', + }; + } + + return { + state: pr.state, + mergedAt: null, + status: 'not-merged', + }; +} + +export function isExternalAuthor(authorAssociation) { + return !INTERNAL_AUTHOR_ASSOCIATIONS.has(String(authorAssociation ?? '').toUpperCase()); +} + +export function isShippedPackageFile(file, root) { + const relative = file.slice(root.length + 1); + if (!relative || relative.startsWith('.')) { + return false; + } + + const basename = path.basename(relative); + if (['CHANGELOG.md', 'LICENSE', 'LICENSE.md', 'README.md', 'package.json'].includes(basename)) { + return false; + } + + if (isTestOrGeneratedFile(relative)) { + return false; + } + + return true; +} + +export function isSourceRuntimeFile(file) { + if (!/\.(ts|tsx|js|mjs|cjs)$/.test(file)) { + return false; + } + return !isTestOrGeneratedFile(file); +} + +export function buildQuickPatchComment(entries) { + const ids = entries.map((entry) => entry.id).sort(); + const firstId = ids[0]; + const patches = ids.length === 1 ? `'${firstId}'` : ids.map((id) => ` '${id}',`).join('\n'); + const patchConfig = ids.length === 1 ? ` patches: [${patches}],` : ` patches: [\n${patches}\n ],`; + + return ` +This fix is available as a quick patch through \`@capgo/capacitor-patch\`. + +Patch ${ids.length === 1 ? 'ID' : 'IDs'}: ${ids.map((id) => `\`${id}\``).join(', ')} + +\`\`\`ts +plugins: { + CapacitorPatch: { +${patchConfig} + strict: true, + }, +} +\`\`\` + +Run \`npx cap sync\` after installing \`@capgo/capacitor-patch\`.`; +} + +export async function syncUpstreamPatches(options) { + const rootDir = path.resolve(options.rootDir ?? process.cwd()); + const capacitorPlusDir = path.resolve(options.capacitorPlusDir); + const remote = options.remote ?? 'origin'; + const baseRef = options.baseRef ?? `${remote}/plus`; + const patchDir = path.join(rootDir, 'patches'); + const catalogPath = path.join(patchDir, 'catalog.json'); + const existingCatalog = readJson(catalogPath); + const catalogById = new Map(existingCatalog.map((entry) => [entry.id, entry])); + let remainingBuildPrs = options.maxBuildPrs ?? 3; + const branches = options.prNumbers?.length + ? options.prNumbers.map((number) => `${remote}/sync/upstream-pr-${number}`) + : listSyncBranches(capacitorPlusDir, remote); + const generatedEntries = []; + const skipped = []; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'capgo-capacitor-patch-sync-')); + + try { + for (const branch of branches) { + const prNumber = parseSyncBranchNumber(branch); + if (!prNumber) { + continue; + } + + const pr = await getPullRequestMetadata(prNumber, options.githubToken); + if (options.externalOnly !== false && !isExternalAuthor(pr.authorAssociation)) { + skipped.push({ pr: prNumber, reason: `internal author association: ${pr.authorAssociation}` }); + continue; + } + + const headSha = git(capacitorPlusDir, ['rev-parse', branch]).trim(); + if (options.requireChecks) { + const checkState = await getCommitCheckState({ + owner: 'Cap-go', + repo: 'capacitor-plus', + ref: headSha, + token: options.githubToken, + }); + if (checkState.state !== 'success') { + skipped.push({ pr: prNumber, reason: `checks are ${checkState.state}: ${checkState.summary}` }); + continue; + } + } + + const mergeBase = git(capacitorPlusDir, ['merge-base', baseRef, branch]).trim(); + const changedFiles = listChangedFiles(capacitorPlusDir, mergeBase, branch); + const { directTargets, compiledTargets } = groupPatchTargets(changedFiles); + const selectedCompiledTargets = compiledTargets.filter( + (target) => options.refreshExisting || !catalogById.has(`upstream-pr-${prNumber}-${target.suffix}`), + ); + const selectedDirectTargets = directTargets.filter( + (target) => options.refreshExisting || !catalogById.has(`upstream-pr-${prNumber}-${target.suffix}`), + ); + + if (!selectedDirectTargets.length && !selectedCompiledTargets.length) { + skipped.push({ pr: prNumber, reason: 'no new patchable package files' }); + continue; + } + + const branchEntries = []; + let skippedCompiledForLimit = false; + for (const target of selectedDirectTargets) { + const files = changedFiles.filter((file) => target.accepts(file)); + const diff = git(capacitorPlusDir, [ + 'diff', + `--relative=${target.root}`, + `${mergeBase}..${branch}`, + '--', + ...files, + ]); + if (!diff.trim()) { + continue; + } + const patchFile = writePatchFile(patchDir, prNumber, target, diff, options.dryRun); + branchEntries.push(createCatalogEntryForTarget({ pr, target, patchFile, prNumber })); + } + + if (selectedCompiledTargets.length) { + if (remainingBuildPrs <= 0) { + skippedCompiledForLimit = true; + skipped.push({ pr: prNumber, reason: 'compiled patch generation limit reached' }); + } else { + remainingBuildPrs -= 1; + const built = generateCompiledDiffs({ + capacitorPlusDir, + mergeBase, + branch, + targets: selectedCompiledTargets, + tmpDir, + }); + + for (const item of built) { + if (!item.diff.trim()) { + continue; + } + const patchFile = writePatchFile(patchDir, prNumber, item.target, item.diff, options.dryRun); + branchEntries.push(createCatalogEntryForTarget({ pr, target: item.target, patchFile, prNumber })); + } + } + } + + if (!branchEntries.length) { + if (!skippedCompiledForLimit) { + skipped.push({ pr: prNumber, reason: 'generated diffs were empty' }); + } + continue; + } + + generatedEntries.push(...branchEntries); + for (const entry of branchEntries) { + catalogById.set(entry.id, entry); + } + } + + const nextCatalog = sortCatalogEntries([...catalogById.values()]); + if (!options.dryRun && generatedEntries.length) { + fs.writeFileSync(catalogPath, `${JSON.stringify(nextCatalog, null, 2)}\n`); + } + + return { + generatedEntries, + skipped, + catalogPath, + }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +export async function commentOnUpstreamPullRequests(options) { + const baseCatalog = readCatalogFromGit(options.baseRef); + const headCatalog = readCatalogFromGit(options.headRef); + const baseIds = new Set(baseCatalog.map((entry) => entry.id)); + const added = headCatalog.filter((entry) => !baseIds.has(entry.id) && entry.source?.upstreamPullRequest); + const byPullRequest = new Map(); + + for (const entry of added) { + const prNumber = getEntryPullRequestNumber(entry); + if (!Number.isFinite(prNumber)) { + continue; + } + const entries = byPullRequest.get(prNumber) ?? []; + entries.push(entry); + byPullRequest.set(prNumber, entries); + } + + const posted = []; + for (const [prNumber, entries] of byPullRequest) { + const body = buildQuickPatchComment(entries); + if (options.dryRun) { + posted.push({ pr: prNumber, entries: entries.map((entry) => entry.id), dryRun: true }); + continue; + } + if (!options.githubToken) { + throw new Error('GITHUB_TOKEN or PERSONAL_ACCESS_TOKEN is required to comment on upstream pull requests.'); + } + + await upsertIssueComment({ + owner: 'ionic-team', + repo: 'capacitor', + issueNumber: prNumber, + token: options.githubToken, + marker: '', + body, + }); + posted.push({ pr: prNumber, entries: entries.map((entry) => entry.id) }); + } + + return { posted, added }; +} + +function createCatalogEntryForTarget({ pr, target, patchFile, prNumber }) { + return createCatalogEntry({ + pr, + target, + patchFile, + upstreamStatus: createUpstreamStatus(pr), + branchUrl: `https://github.com/Cap-go/capacitor-plus/tree/sync/upstream-pr-${prNumber}`, + }); +} + +function writePatchFile(patchDir, prNumber, target, diff, dryRun) { + const patchFile = `patches/upstream-pr-${prNumber}-${target.suffix}.patch`; + if (!dryRun) { + fs.writeFileSync(path.join(patchDir, path.basename(patchFile)), diff.endsWith('\n') ? diff : `${diff}\n`); + } + return patchFile; +} + +function listSyncBranches(repoDir, remote) { + const output = git(repoDir, [ + 'for-each-ref', + '--format=%(refname:short)', + `refs/remotes/${remote}/sync/upstream-pr-*`, + ]); + return output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .sort((a, b) => (parseSyncBranchNumber(a) ?? 0) - (parseSyncBranchNumber(b) ?? 0)); +} + +function listChangedFiles(repoDir, baseRef, headRef) { + return git(repoDir, ['diff', '--name-only', `${baseRef}..${headRef}`]) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +function generateCompiledDiffs({ capacitorPlusDir, mergeBase, branch, targets, tmpDir }) { + const baseDir = path.join(tmpDir, `base-${process.pid}-${Date.now()}`); + const headDir = path.join(tmpDir, `head-${process.pid}-${Date.now()}`); + git(capacitorPlusDir, ['worktree', 'add', '--detach', baseDir, mergeBase]); + git(capacitorPlusDir, ['worktree', 'add', '--detach', headDir, branch]); + + try { + buildNeededTargets(baseDir, targets); + buildNeededTargets(headDir, targets); + + return targets.map((target) => ({ + target, + diff: createFileSetDiff({ + baseRoot: path.join(baseDir, target.root), + headRoot: path.join(headDir, target.root), + files: expandGeneratedFiles(path.join(headDir, target.root), target.generatedFiles), + }), + })); + } finally { + git(capacitorPlusDir, ['worktree', 'remove', '--force', baseDir], { allowFailure: true }); + git(capacitorPlusDir, ['worktree', 'remove', '--force', headDir], { allowFailure: true }); + } +} + +function buildNeededTargets(worktreeDir, targets) { + const buildTypes = new Set(targets.map((target) => target.build)); + if (!buildTypes.size) { + return; + } + + run('bun', ['install', '--frozen-lockfile'], { cwd: worktreeDir }); + + if (buildTypes.has('nativebridge')) { + run('bunx', ['tsc', 'native-bridge.ts', '--target', 'es2017', '--moduleResolution', 'node', '--outDir', 'build'], { + cwd: path.join(worktreeDir, 'core'), + }); + run('bunx', ['rollup', '--config', 'rollup.bridge.config.js'], { cwd: path.join(worktreeDir, 'core') }); + } + + if (buildTypes.has('core')) { + run('bun', ['run', 'clean'], { cwd: path.join(worktreeDir, 'core') }); + run('bunx', ['tsc'], { cwd: path.join(worktreeDir, 'core') }); + run('bunx', ['rollup', '--config', 'rollup.config.js'], { cwd: path.join(worktreeDir, 'core') }); + } + + if (buildTypes.has('cli')) { + run('bun', ['run', 'clean'], { cwd: path.join(worktreeDir, 'cli') }); + run('bun', ['run', 'assets'], { cwd: path.join(worktreeDir, 'cli') }); + run('bunx', ['tsc'], { cwd: path.join(worktreeDir, 'cli') }); + } +} + +function createFileSetDiff({ baseRoot, headRoot, files }) { + if (!files.length) { + return ''; + } + + const diffRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'capgo-capacitor-patch-diff-')); + try { + copySelectedFiles(baseRoot, diffRoot, files); + git(diffRoot, ['init', '--quiet']); + git(diffRoot, ['add', '-A']); + copySelectedFiles(headRoot, diffRoot, files); + git(diffRoot, ['add', '-N', '.'], { allowFailure: true }); + return git(diffRoot, ['diff', '--', ...files], { allowFailure: true }); + } finally { + fs.rmSync(diffRoot, { recursive: true, force: true }); + } +} + +function expandGeneratedFiles(rootDir, entries) { + const files = []; + for (const entry of entries) { + const absolute = path.join(rootDir, entry); + if (!fs.existsSync(absolute)) { + continue; + } + const stat = fs.statSync(absolute); + if (stat.isFile()) { + files.push(entry); + continue; + } + for (const file of walkFiles(absolute)) { + const relative = path.relative(rootDir, file).split(path.sep).join('/'); + if (!isTestOrGeneratedFile(relative) && !relative.endsWith('.map')) { + files.push(relative); + } + } + } + return files.sort(); +} + +function copySelectedFiles(sourceRoot, destRoot, files) { + for (const file of files) { + const source = path.join(sourceRoot, file); + const dest = path.join(destRoot, file); + if (!fs.existsSync(source)) { + fs.rmSync(dest, { force: true }); + continue; + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(source, dest); + } +} + +function walkFiles(rootDir) { + const result = []; + const stack = [rootDir]; + while (stack.length) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(absolute); + } else if (entry.isFile()) { + result.push(absolute); + } + } + } + return result; +} + +function isTestOrGeneratedFile(file) { + const normalized = file.split(path.sep).join('/'); + const basename = path.basename(normalized); + return ( + normalized.includes('/build/') || + normalized.includes('/coverage/') || + normalized.includes('/test/') || + normalized.includes('/tests/') || + normalized.includes('/__tests__/') || + /\.(spec|test)\.[cm]?[jt]sx?$/.test(basename) || + basename.endsWith('.map') + ); +} + +async function getPullRequestMetadata(number, token) { + const fallback = { + number, + title: `Upstream Capacitor PR #${number}`, + state: 'unknown', + mergedAt: null, + author: 'unknown', + authorAssociation: 'NONE', + }; + + const data = await githubJson(`/repos/ionic-team/capacitor/pulls/${number}`, token, { optional: true }); + if (!data) { + return fallback; + } + + return { + number, + title: data.title, + state: data.state, + mergedAt: data.merged_at, + author: data.user?.login ?? 'unknown', + authorAssociation: data.author_association ?? 'NONE', + }; +} + +async function getCommitCheckState({ owner, repo, ref, token }) { + const checkRuns = await githubJson(`/repos/${owner}/${repo}/commits/${ref}/check-runs?per_page=100`, token, { + optional: true, + }); + const status = await githubJson(`/repos/${owner}/${repo}/commits/${ref}/status`, token, { optional: true }); + const runs = checkRuns?.check_runs ?? []; + const statuses = status?.statuses ?? []; + const pendingRuns = runs.filter((run) => run.status !== 'completed'); + const failedRuns = runs.filter((run) => FAILED_CHECK_CONCLUSIONS.has(run.conclusion)); + const failedStatuses = statuses.filter((item) => item.state !== 'success'); + + if (!runs.length && !statuses.length) { + return { state: 'missing', summary: 'no check runs or statuses found' }; + } + if (pendingRuns.length) { + return { state: 'pending', summary: pendingRuns.map((run) => run.name).join(', ') }; + } + if (failedRuns.length || failedStatuses.length) { + return { + state: 'failure', + summary: [ + ...failedRuns.map((run) => `${run.name}:${run.conclusion}`), + ...failedStatuses.map((item) => item.context), + ].join(', '), + }; + } + if (runs.some((run) => !SUCCESSFUL_CHECK_CONCLUSIONS.has(run.conclusion))) { + return { state: 'unknown', summary: 'one or more check runs have unknown conclusions' }; + } + return { state: 'success', summary: `${runs.length + statuses.length} checks passed` }; +} + +async function upsertIssueComment({ owner, repo, issueNumber, token, marker, body }) { + const comments = await githubJson(`/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=100`, token); + const existing = comments.find((comment) => comment.body?.includes(marker)); + if (existing) { + await githubJson(`/repos/${owner}/${repo}/issues/comments/${existing.id}`, token, { + method: 'PATCH', + body: { body }, + }); + return; + } + await githubJson(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, token, { + method: 'POST', + body: { body }, + }); +} + +async function githubJson(endpoint, token, options = {}) { + const response = await fetch(`https://api.github.com${endpoint}`, { + method: options.method ?? 'GET', + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + if (options.optional && response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`GitHub API ${response.status} ${response.statusText}: ${await response.text()}`); + } + + return response.status === 204 ? null : response.json(); +} + +function readCatalogFromGit(ref) { + try { + return JSON.parse(run('git', ['show', `${ref}:patches/catalog.json`], { allowFailure: false })); + } catch { + return []; + } +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function git(cwd, args, options = {}) { + return run('git', ['-C', cwd, ...args], options); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`); + } + + return result.stdout; +} diff --git a/scripts/comment-upstream-patches.mjs b/scripts/comment-upstream-patches.mjs new file mode 100644 index 0000000..e139e74 --- /dev/null +++ b/scripts/comment-upstream-patches.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; + +import { commentOnUpstreamPullRequests } from './capacitor-patch/upstream-sync.mjs'; + +const options = parseArgs(process.argv.slice(2)); + +if (!options.baseRef || !options.headRef) { + console.error('Missing --base and --head .'); + process.exit(2); +} + +try { + const token = getGitHubToken(); + if (!token && !options.dryRun) { + console.log('[patch-comment] no token configured; skipping upstream comments.'); + process.exit(0); + } + + const result = await commentOnUpstreamPullRequests({ + baseRef: options.baseRef, + headRef: options.headRef, + githubToken: token, + dryRun: options.dryRun, + }); + + for (const posted of result.posted) { + console.log(`[patch-comment] ${options.dryRun ? 'would comment on' : 'commented on'} upstream PR #${posted.pr}`); + } + if (!result.posted.length) { + console.log('[patch-comment] no new upstream patch entries found.'); + } +} catch (error) { + console.error(`[patch-comment] ${error?.stack || error?.message || error}`); + process.exit(1); +} + +function parseArgs(argv) { + const options = { + baseRef: '', + headRef: '', + dryRun: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--base') { + options.baseRef = argv[++index]; + } else if (arg === '--head') { + options.headRef = argv[++index]; + } else if (arg === '--dry-run') { + options.dryRun = true; + } else { + throw new Error(`Unknown option: ${arg}`); + } + } + + return options; +} + +function getGitHubToken() { + const envToken = process.env.PERSONAL_ACCESS_TOKEN || process.env.GITHUB_TOKEN; + if (envToken) { + return envToken; + } + + const result = spawnSync('gh', ['auth', 'token'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + + return result.status === 0 ? result.stdout.trim() : ''; +} diff --git a/scripts/sync-upstream-patches.mjs b/scripts/sync-upstream-patches.mjs new file mode 100644 index 0000000..cf59c37 --- /dev/null +++ b/scripts/sync-upstream-patches.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; + +import { syncUpstreamPatches } from './capacitor-patch/upstream-sync.mjs'; + +const options = parseArgs(process.argv.slice(2)); + +if (!options.capacitorPlusDir) { + console.error('Missing --capacitor-plus-dir .'); + process.exit(2); +} + +try { + const result = await syncUpstreamPatches({ + rootDir: process.cwd(), + capacitorPlusDir: options.capacitorPlusDir, + remote: options.remote, + baseRef: options.baseRef, + githubToken: getGitHubToken(), + requireChecks: options.requireChecks, + externalOnly: options.externalOnly, + refreshExisting: options.refreshExisting, + dryRun: options.dryRun, + maxBuildPrs: options.maxBuildPrs, + prNumbers: options.prNumbers, + }); + + for (const entry of result.generatedEntries) { + console.log(`[patch-sync] generated ${entry.id} -> ${entry.patchFile}`); + } + for (const skipped of result.skipped) { + console.log(`[patch-sync] skipped PR #${skipped.pr}: ${skipped.reason}`); + } + if (!result.generatedEntries.length) { + console.log('[patch-sync] no new patches generated.'); + } +} catch (error) { + console.error(`[patch-sync] ${error?.stack || error?.message || error}`); + process.exit(1); +} + +function parseArgs(argv) { + const options = { + remote: 'origin', + baseRef: '', + requireChecks: false, + externalOnly: true, + refreshExisting: false, + dryRun: false, + maxBuildPrs: 3, + prNumbers: [], + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--capacitor-plus-dir') { + options.capacitorPlusDir = argv[++index]; + } else if (arg === '--remote') { + options.remote = argv[++index]; + } else if (arg === '--base-ref') { + options.baseRef = argv[++index]; + } else if (arg === '--require-checks') { + options.requireChecks = true; + } else if (arg === '--no-require-checks') { + options.requireChecks = false; + } else if (arg === '--include-internal') { + options.externalOnly = false; + } else if (arg === '--refresh-existing') { + options.refreshExisting = true; + } else if (arg === '--dry-run') { + options.dryRun = true; + } else if (arg === '--max-build-prs') { + options.maxBuildPrs = Number(argv[++index]); + } else if (arg === '--pr') { + options.prNumbers.push(Number(argv[++index])); + } else { + throw new Error(`Unknown option: ${arg}`); + } + } + + if (!options.baseRef) { + options.baseRef = `${options.remote}/plus`; + } + + return options; +} + +function getGitHubToken() { + const envToken = process.env.GITHUB_TOKEN || process.env.PERSONAL_ACCESS_TOKEN; + if (envToken) { + return envToken; + } + + const result = spawnSync('gh', ['auth', 'token'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + + return result.status === 0 ? result.stdout.trim() : ''; +} diff --git a/scripts/test-upstream-sync.mjs b/scripts/test-upstream-sync.mjs new file mode 100644 index 0000000..538e7c0 --- /dev/null +++ b/scripts/test-upstream-sync.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildQuickPatchComment, + createCatalogEntry, + createUpstreamStatus, + groupPatchTargets, + isExternalAuthor, + parseSyncBranchNumber, + sortCatalogEntries, +} from './capacitor-patch/upstream-sync.mjs'; + +test('sync branch parser extracts upstream PR numbers', () => { + assert.equal(parseSyncBranchNumber('origin/sync/upstream-pr-8418'), 8418); + assert.equal(parseSyncBranchNumber('capgo/sync/upstream-pr-6991'), 6991); + assert.equal(parseSyncBranchNumber('origin/main'), null); +}); + +test('external author detection treats Capacitor team roles as internal', () => { + assert.equal(isExternalAuthor('CONTRIBUTOR'), true); + assert.equal(isExternalAuthor('NONE'), true); + assert.equal(isExternalAuthor('FIRST_TIMER'), true); + assert.equal(isExternalAuthor('MEMBER'), false); + assert.equal(isExternalAuthor('OWNER'), false); + assert.equal(isExternalAuthor('COLLABORATOR'), false); +}); + +test('target grouping keeps shipped package files and excludes tests', () => { + const grouped = groupPatchTargets([ + 'android/capacitor/src/main/java/com/getcapacitor/Bridge.java', + 'android/capacitor/src/test/java/com/getcapacitor/BridgeTest.java', + 'ios/Capacitor/Capacitor/CAPBridgeViewController.swift', + 'core/src/web-plugin.ts', + 'core/src/tests/web-plugin.spec.ts', + 'core/native-bridge.ts', + 'cli/src/ios/build.ts', + 'cli/src/tasks/build.spec.ts', + 'README.md', + ]); + + assert.deepEqual( + grouped.directTargets.map((target) => target.key), + ['android', 'ios'], + ); + assert.deepEqual( + grouped.compiledTargets.map((target) => target.key), + ['core', 'cli', 'android-native-bridge', 'ios-native-bridge'], + ); +}); + +test('catalog entries use stable IDs and external source metadata', () => { + const entry = createCatalogEntry({ + pr: { + number: 8418, + title: 'fix(android): range request truncation', + author: 'bwees', + authorAssociation: 'CONTRIBUTOR', + state: 'open', + mergedAt: null, + }, + target: { + packageName: '@capacitor/android', + suffix: 'android', + titleSuffix: 'android', + versionRange: '>=8.0.0 <9.0.0', + }, + patchFile: 'patches/upstream-pr-8418-android.patch', + upstreamStatus: createUpstreamStatus({ state: 'open', mergedAt: null }), + branchUrl: 'https://github.com/Cap-go/capacitor-plus/tree/sync/upstream-pr-8418', + }); + + assert.equal(entry.id, 'upstream-pr-8418-android'); + assert.equal(entry.source.authorAssociation, 'external'); + assert.equal(entry.upstream.status, 'not-merged'); + assert.equal(entry.target.packageName, '@capacitor/android'); +}); + +test('catalog sorting is stable by upstream PR and patch ID', () => { + const sorted = sortCatalogEntries([ + { id: 'upstream-pr-9000-ios' }, + { id: 'upstream-pr-8418-android' }, + { id: 'upstream-pr-6991-ios' }, + { id: 'upstream-pr-6991-android' }, + ]); + + assert.deepEqual( + sorted.map((entry) => entry.id), + ['upstream-pr-6991-android', 'upstream-pr-6991-ios', 'upstream-pr-8418-android', 'upstream-pr-9000-ios'], + ); +}); + +test('quick patch comment supports one or many patch IDs', () => { + const single = buildQuickPatchComment([{ id: 'upstream-pr-8418-android' }]); + assert.match(single, /Patch ID: `upstream-pr-8418-android`/); + assert.match(single, /patches: \['upstream-pr-8418-android'\]/); + + const multiple = buildQuickPatchComment([{ id: 'upstream-pr-6991-ios' }, { id: 'upstream-pr-6991-android' }]); + assert.match(multiple, /Patch IDs: `upstream-pr-6991-android`, `upstream-pr-6991-ios`/); + assert.match(multiple, /patches: \[/); +});