diff --git a/.github/workflows/migrate-toolkit-settings.yml b/.github/workflows/migrate-toolkit-settings.yml new file mode 100644 index 000000000..2c55db73d --- /dev/null +++ b/.github/workflows/migrate-toolkit-settings.yml @@ -0,0 +1,92 @@ +name: Migrate toolkit settings + +on: + workflow_dispatch: + +permissions: {} + +concurrency: + group: migrate-toolkit-settings + cancel-in-progress: false + +jobs: + migrate: + if: >- + github.repository_id == '1114546946' && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: production + env: + GH_TOKEN: ${{ secrets.SENTRY_MCP_MIGRATION_PAT }} + TARGET_REPOSITORY: getsentry/sentry-mcp + TARGET_REPOSITORY_ID: '957245447' + AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_CERT_DATA: ${{ secrets.APPLE_CERT_DATA }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + steps: + - name: Validate migration inputs + shell: bash + run: | + set -euo pipefail + : "${GH_TOKEN:?SENTRY_MCP_MIGRATION_PAT is not configured}" + : "${AI_GATEWAY_API_KEY:?AI_GATEWAY_API_KEY is not configured}" + : "${ANTHROPIC_API_KEY:?ANTHROPIC_API_KEY is not configured}" + : "${OPENROUTER_API_KEY:?OPENROUTER_API_KEY is not configured}" + : "${SENTRY_CLIENT_ID:?SENTRY_CLIENT_ID is not configured}" + : "${APPLE_API_KEY:?APPLE_API_KEY is not configured}" + : "${APPLE_CERT_DATA:?APPLE_CERT_DATA is not configured}" + : "${APPLE_CERT_PASSWORD:?APPLE_CERT_PASSWORD is not configured}" + : "${APPLE_TEAM_ID:?APPLE_TEAM_ID is not configured}" + : "${SENTRY_AUTH_TOKEN:?SENTRY_AUTH_TOKEN is not configured}" + + test "$(gh api "repos/${TARGET_REPOSITORY}" --jq .id)" = "${TARGET_REPOSITORY_ID}" + + - name: Create destination production environment + shell: bash + run: | + set -euo pipefail + jq -n '{deployment_branch_policy: { + protected_branches: true, + custom_branch_policies: false + }}' | gh api --method PUT "repos/${TARGET_REPOSITORY}/environments/production" --input - >/dev/null + + - name: Copy repository settings + shell: bash + run: | + set -euo pipefail + set +x + printf '%s' "${AI_GATEWAY_API_KEY}" | gh secret set AI_GATEWAY_API_KEY --repo "${TARGET_REPOSITORY}" --app actions + printf '%s' "${ANTHROPIC_API_KEY}" | gh secret set ANTHROPIC_API_KEY --repo "${TARGET_REPOSITORY}" --app actions + printf '%s' "${OPENROUTER_API_KEY}" | gh secret set OPENROUTER_API_KEY --repo "${TARGET_REPOSITORY}" --app actions + printf '%s' "${SENTRY_CLIENT_ID}" | gh variable set SENTRY_CLIENT_ID --repo "${TARGET_REPOSITORY}" + + - name: Copy production environment secrets + shell: bash + run: | + set -euo pipefail + set +x + printf '%s' "${APPLE_API_KEY}" | gh secret set APPLE_API_KEY --repo "${TARGET_REPOSITORY}" --env production --app actions + printf '%s' "${APPLE_CERT_DATA}" | gh secret set APPLE_CERT_DATA --repo "${TARGET_REPOSITORY}" --env production --app actions + printf '%s' "${APPLE_CERT_PASSWORD}" | gh secret set APPLE_CERT_PASSWORD --repo "${TARGET_REPOSITORY}" --env production --app actions + printf '%s' "${APPLE_TEAM_ID}" | gh secret set APPLE_TEAM_ID --repo "${TARGET_REPOSITORY}" --env production --app actions + printf '%s' "${SENTRY_AUTH_TOKEN}" | gh secret set SENTRY_AUTH_TOKEN --repo "${TARGET_REPOSITORY}" --env production --app actions + + - name: Verify destination setting names + shell: bash + run: | + set -euo pipefail + test "$(gh api "repos/${TARGET_REPOSITORY}/environments/production" --jq '.deployment_branch_policy | "\(.protected_branches):\(.custom_branch_policies)"')" = 'true:false' + diff -u \ + <(printf '%s\n' AI_GATEWAY_API_KEY ANTHROPIC_API_KEY OPENROUTER_API_KEY | sort) \ + <(gh secret list --repo "${TARGET_REPOSITORY}" --json name --jq '.[].name' | grep -E '^(AI_GATEWAY_API_KEY|ANTHROPIC_API_KEY|OPENROUTER_API_KEY)$' | sort) + test "$(gh variable get SENTRY_CLIENT_ID --repo "${TARGET_REPOSITORY}" --json name --jq .name)" = SENTRY_CLIENT_ID + diff -u \ + <(printf '%s\n' APPLE_API_KEY APPLE_CERT_DATA APPLE_CERT_PASSWORD APPLE_TEAM_ID SENTRY_AUTH_TOKEN | sort) \ + <(gh secret list --repo "${TARGET_REPOSITORY}" --env production --json name --jq '.[].name' | grep -E '^(APPLE_API_KEY|APPLE_CERT_DATA|APPLE_CERT_PASSWORD|APPLE_TEAM_ID|SENTRY_AUTH_TOKEN)$' | sort) diff --git a/.gitignore b/.gitignore index 8dc6c7f09..36fa5fbd8 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ apps/cli-docs/.astro # local planning notes (not for version control) .plans +cli-open-pr-migration-plan.json # IntelliJ based IDEs .idea diff --git a/package.json b/package.json index cb501ad19..7f4fa57a0 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "node": ">=20.0" }, "scripts": { + "migrate:open-prs": "node scripts/migrate-open-prs.mjs", + "test:migrate-open-prs": "node --test scripts/migrate-open-prs.test.mjs", "tsx": "pnpm --filter sentry run tsx", "cli": "pnpm --filter sentry run cli", "dev": "pnpm --filter sentry run dev", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..fdff0dca2 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,90 @@ +# Open CLI pull request migration + +`migrate-open-prs.mjs` migrates open pull requests from `getsentry/cli` to +`getsentry/sentry-mcp` after the CLI and CLI documentation subtree imports. +It transfers changes only under `packages/cli/` and `apps/cli-docs/`. For pull +requests based before the monorepo layout commit, it uses an explicit old CLI +mapping whose source and destination boundaries are verified against that +immutable commit and maps those roots into +`packages/cli/`; it maps only the explicitly moved children of `docs/` into +`apps/cli-docs/`. A path that remained at the repository root or under `docs/` +is excluded even when the layout commit created a same-named path in an +imported destination. Unknown paths remain excluded as repository +administration changes. + +## 1. Export and review a plan + +```sh +pnpm migrate:open-prs --output cli-open-pr-migration-plan.json +``` + +This is always the default mode. It reads GitHub, clones into a temporary +directory, reconstructs commits, verifies exact scoped diffs, and writes one +new plan file. It never changes either repository. The command refuses to +overwrite an existing plan; choose a new path or remove the old plan after +reviewing it. + +Review every entry, especially `mappedPaths` and `excludedPaths`. Root +workflows, repository administration, and all other paths stay in +`getsentry/cli`. An entry with no imported-path changes has `action: "skip"`. + +The plan pins: + +- every source PR's metadata, open state, head and advertised base SHA; +- source cutoff `fc1140092883409046438c8bb5d6f7f939eb9659`; +- filtered import tips `6b08e9029dc7e092bfebaa3870ca116e7dfdfc89` and + `4358ec2729a60ef034f3866680fc5fd7bce62ce0`; +- each current source target, merge base, source layout, reconstructed + destination commit, and byte-exact scoped change-manifest hash; +- stack relationships derived from exact source repository and base branch; +- destination-valid labels, assignees, reviewers, teams, and milestone mapping. + +An advertised PR base SHA may be older than current `main` or its stack +parent. The tool resolves and pins the current target by repository and branch, +then computes the merge base. Linear histories replay commit by commit. A +history containing merge commits becomes one net-change commit with the source +head's author metadata. The final mapped status, file mode, and blob IDs must +match exactly. + +The command fails on moved sources, binary changes, renames or copies, +symlinks, submodules, unsafe or ambiguous paths, patch conflicts, missing +import tips, and non-exact reconstructed changes. + +Source `main` may advance past the recorded cutoff only through changes outside +`packages/cli` and `apps/cli-docs`. Any change under those imported paths +requires another subtree import before PR migration can continue. + +## 2. Execute the reviewed plan + +```sh +pnpm migrate:open-prs --execute --plan cli-open-pr-migration-plan.json +``` + +Execution fetches all source state again, rebuilds the plan from scratch, and +requires an exact match before the first write. Immediately before every +GitHub write, it fetches and compares every source target and destination base +in the entry's full stack against the plan. This includes skipped stack +parents. Any drift stops that phase before its write and prevents all later +mutations. It pushes +`migrated/cli-pr-N`, creates each destination PR as a draft, verifies the +remote head, base, and diff, then copies destination-valid metadata. New pull +requests start in their planned draft/ready state. Same-repository and fork PRs use the +source repository's immutable pull ref, while the snapshot retains the original +head repository. Only after verification does the script comment on +the source PR with the reciprocal link. + +New destination branches use an atomic push with a must-not-exist +`--force-with-lease` guard. Existing destination branches are never pushed. A +partial run can resume when the existing branch SHA and exact scoped diff match +the plan. If the earlier run pushed the branch but failed before PR creation, +the rerun creates the missing PR. An existing pull request must have the exact +planned title, body marker, draft state, and either a known partial or complete +set of migration-owned labels, assignees, milestone, and reviewers. User-edited +or unknown metadata fails closed before any write. Any other branch or PR state +also fails closed. + +Source PRs always stay open. GitHub cannot atomically create the destination PR +and close the source PR, so this tool never offers or performs source closure. + +Use `--keep-temp` to retain the temporary clone after success or failure for +manual inspection. diff --git a/scripts/lib/pr-migration-plan.mjs b/scripts/lib/pr-migration-plan.mjs new file mode 100644 index 000000000..9b30513b6 --- /dev/null +++ b/scripts/lib/pr-migration-plan.mjs @@ -0,0 +1,511 @@ +import { createHash } from "node:crypto"; + +export const SOURCE_REPOSITORY = "getsentry/cli"; +export const DESTINATION_REPOSITORY = "getsentry/sentry-mcp"; +export const SOURCE_MAIN_CUTOFF = "fc1140092883409046438c8bb5d6f7f939eb9659"; +export const SOURCE_LAYOUT_COMMIT = "96390e5f6953aa61ed2e90687f2d6858478c708c"; +export const FILTERED_TIPS = Object.freeze([ + "6b08e9029dc7e092bfebaa3870ca116e7dfdfc89", + "4358ec2729a60ef034f3866680fc5fd7bce62ce0", +]); +export const MIGRATED_PATHS = Object.freeze(["packages/cli", "apps/cli-docs"]); +export const DEFAULT_PLAN = "cli-open-pr-migration-plan.json"; +export const MIGRATION_WRITE_PHASES = Object.freeze([ + "push-branch", + "create-pull", + "update-metadata", + "request-reviewers", + "comment-source", +]); + +const EMPTY_LEGACY_MAPPING = Object.freeze({ + cliRoots: new Set(), + docsRoots: new Set(), +}); + +/** Exact roots moved by the immutable source layout commit. */ +export const LEGACY_PATH_MOVES = Object.freeze( + [ + [".claude-plugin", "packages/cli/.claude-plugin"], + [".cursor", "packages/cli/.cursor"], + [".env.example", "packages/cli/.env.example"], + [".vscode", "packages/cli/.vscode"], + ["CONTRIBUTING.md", "packages/cli/CONTRIBUTING.md"], + ["DEVELOPMENT.md", "packages/cli/DEVELOPMENT.md"], + ["assets", "packages/cli/assets"], + ["biome.jsonc", "packages/cli/biome.jsonc"], + ["codecov.yml", "packages/cli/codecov.yml"], + ["install", "packages/cli/install"], + ["lint-rules", "packages/cli/lint-rules"], + ["patches", "packages/cli/patches"], + ["plugins", "packages/cli/plugins"], + ["script", "packages/cli/script"], + ["src", "packages/cli/src"], + ["test", "packages/cli/test"], + ["tsconfig.json", "packages/cli/tsconfig.json"], + ["vitest.config.ts", "packages/cli/vitest.config.ts"], + ["warden.toml", "packages/cli/warden.toml"], + ["docs/astro.config.mjs", "apps/cli-docs/astro.config.mjs"], + ["docs/package.json", "apps/cli-docs/package.json"], + ["docs/public", "apps/cli-docs/public"], + ["docs/sentry.client.config.js", "apps/cli-docs/sentry.client.config.js"], + ["docs/sentry.server.config.js", "apps/cli-docs/sentry.server.config.js"], + ["docs/src", "apps/cli-docs/src"], + ["docs/tsconfig.json", "apps/cli-docs/tsconfig.json"], + ].map((move) => Object.freeze(move)) +); + +/** Compares strings by JavaScript code units without consulting the locale. */ +export function compareCodeUnits(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function text(value) { + return value ?? ""; +} + +function names(values) { + return (values ?? []) + .map((value) => value.login ?? value.name) + .sort(compareCodeUnits); +} + +/** Reduces GitHub's pull response to the fields whose movement invalidates a plan. */ +export function snapshotPull(pull) { + if (!(pull.head?.repo?.full_name && pull.base?.repo?.full_name)) { + throw new Error(`PR #${pull.number} has a deleted head or base repository`); + } + + return { + number: pull.number, + title: pull.title, + body: text(pull.body), + author: pull.user?.login ?? "", + state: pull.state, + draft: Boolean(pull.draft), + head: { + repository: pull.head.repo.full_name, + branch: pull.head.ref, + sha: pull.head.sha, + }, + base: { + repository: pull.base.repo.full_name, + branch: pull.base.ref, + sha: pull.base.sha, + }, + labels: (pull.labels ?? []) + .map((label) => label.name) + .sort(compareCodeUnits), + assignees: names(pull.assignees), + reviewers: names(pull.requested_reviewers), + reviewerTeams: (pull.requested_teams ?? []) + .map((team) => team.slug) + .sort(compareCodeUnits), + milestone: pull.milestone?.title ?? null, + url: pull.html_url, + }; +} + +function headKey(repository, branch) { + return `${repository}\0${branch}`; +} + +/** Maps source base branches to open PR heads and returns parent-first entries. */ +export function buildStackPlan(snapshots, sourceDefaultBranch = "main") { + const byHead = new Map(); + for (const snapshot of snapshots) { + const key = headKey(snapshot.head.repository, snapshot.head.branch); + if (byHead.has(key)) { + throw new Error( + `ambiguous open PR head ${snapshot.head.repository}:${snapshot.head.branch}` + ); + } + byHead.set(key, snapshot); + } + + const entries = snapshots.map((snapshot) => { + if (snapshot.base.repository !== SOURCE_REPOSITORY) { + throw new Error( + `PR #${snapshot.number} targets unexpected repository ${snapshot.base.repository}` + ); + } + const parent = byHead.get( + headKey(snapshot.base.repository, snapshot.base.branch) + ); + if (!parent && snapshot.base.branch !== sourceDefaultBranch) { + throw new Error( + `PR #${snapshot.number} targets ${snapshot.base.repository}:${snapshot.base.branch}, ` + + "which is neither main nor another open PR head" + ); + } + if (parent?.number === snapshot.number) { + throw new Error(`PR #${snapshot.number} targets its own head branch`); + } + return { + snapshot, + parentNumber: parent?.number ?? null, + destinationBranch: `migrated/cli-pr-${snapshot.number}`, + destinationBase: parent ? `migrated/cli-pr-${parent.number}` : "main", + }; + }); + + const pending = new Map( + entries.map((entry) => [entry.snapshot.number, entry]) + ); + const ordered = []; + while (pending.size > 0) { + const ready = [...pending.values()] + .filter( + (entry) => + entry.parentNumber === null || !pending.has(entry.parentNumber) + ) + .sort((left, right) => left.snapshot.number - right.snapshot.number); + if (ready.length === 0) { + throw new Error("open PR base mapping contains a cycle"); + } + for (const entry of ready) { + ordered.push(entry); + pending.delete(entry.snapshot.number); + } + } + return ordered; +} + +export function isMigratedPath(path) { + return MIGRATED_PATHS.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`) + ); +} + +/** Parses migration arguments. Execution never includes source-PR closure. */ +export function parseArgs(argv) { + const options = { + execute: false, + keepTemp: false, + output: DEFAULT_PLAN, + plan: DEFAULT_PLAN, + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--execute") options.execute = true; + else if (argument === "--keep-temp") options.keepTemp = true; + else if (argument === "--help") options.help = true; + else if (argument === "--output" || argument === "--plan") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${argument} requires a path`); + } + options[argument === "--output" ? "output" : "plan"] = value; + index += 1; + } else { + throw new Error(`unknown argument ${argument}`); + } + } + return options; +} + +/** Builds path lookup sets from the explicit immutable layout mapping. */ +export function createLegacyPathMapping() { + const cliRoots = new Set(); + const docsRoots = new Set(); + for (const [source, destination] of LEGACY_PATH_MOVES) { + if (destination.startsWith("packages/cli/")) cliRoots.add(source); + else if (destination.startsWith("apps/cli-docs/")) { + docsRoots.add(source.slice("docs/".length)); + } else throw new Error(`unknown legacy destination ${destination}`); + } + return { cliRoots, docsRoots }; +} + +/** Maps one source-era path using lineage from the immutable layout commit. */ +export function mapSourcePath( + path, + layout, + legacyMapping = EMPTY_LEGACY_MAPPING +) { + if (path.startsWith("/") || path.split("/").includes("..")) { + throw new Error(`unsafe changed path ${JSON.stringify(path)}`); + } + if (layout === "monorepo") return isMigratedPath(path) ? path : null; + if (layout !== "legacy") throw new Error(`unknown source layout ${layout}`); + if (path.startsWith("docs/")) { + const docsPath = path.slice("docs/".length); + const docsRoot = docsPath.split("/", 1)[0]; + return legacyMapping.docsRoots.has(docsRoot) + ? `apps/cli-docs/${docsPath}` + : null; + } + const root = path.split("/", 1)[0]; + return legacyMapping.cliRoots.has(root) ? `packages/cli/${path}` : null; +} + +/** Classifies source paths and reports their mapped destination paths. */ +export function classifySourcePaths( + paths, + layout, + legacyMapping = EMPTY_LEGACY_MAPPING +) { + const included = []; + const excluded = []; + const mapped = []; + for (const path of paths) { + const destination = mapSourcePath(path, layout, legacyMapping); + if (destination) { + included.push(path); + mapped.push(destination); + } else { + excluded.push(path); + } + } + return { + included: [...new Set(included)].sort(compareCodeUnits), + excluded: [...new Set(excluded)].sort(compareCodeUnits), + mapped: [...new Set(mapped)].sort(compareCodeUnits), + }; +} + +/** Returns the first parent used to replay a commit's net first-parent change. */ +export function replayParent(parents, commit) { + if (parents.length === 0) { + throw new Error(`root commit edge case at ${commit}`); + } + return parents[0]; +} + +/** Chooses commit replay unless any merge requires one verified net change. */ +export function reconstructionMode(parentCounts) { + return parentCounts.some((count) => count > 1) ? "net" : "commits"; +} + +/** Builds a stable, byte-identity manifest after applying source path mapping. */ +export function buildMappedManifest(changes, mapPath) { + const manifest = []; + for (const change of changes) { + const path = mapPath(change.path); + if (!path) continue; + manifest.push({ + blob: change.blob, + mode: change.mode, + path, + status: change.status, + }); + } + return manifest.sort((left, right) => + compareCodeUnits(left.path, right.path) + ); +} + +/** Decides whether an exact destination branch can be resumed safely. */ +export function destinationRecoveryAction(state) { + if (!state.branchExists) return state.pullExists ? "reject" : "push"; + if (state.branchSha !== state.expectedHeadSha || !state.diffMatches) { + return "reject"; + } + return state.pullExists ? "reuse" : "create-pull"; +} + +/** + * Re-resolves every source target and destination base in an entry's stack. + * This includes skipped parents because they can still determine either base. + */ +export async function verifyWriteReferences( + entry, + entriesByNumber, + resolveSourceTarget, + resolveDestinationBase +) { + const visited = new Set(); + for (let current = entry; current; ) { + if (visited.has(current.snapshot.number)) { + throw new Error( + `cycle while verifying write references for PR #${entry.snapshot.number}` + ); + } + visited.add(current.snapshot.number); + + const sourceTargetSha = await resolveSourceTarget(current); + if (sourceTargetSha !== current.sourceTargetSha) { + throw new Error( + `source target for PR #${current.snapshot.number} moved to ${sourceTargetSha}, expected ${current.sourceTargetSha}` + ); + } + const destinationBaseSha = await resolveDestinationBase(current); + if (destinationBaseSha !== current.destinationBaseSha) { + throw new Error( + `destination base for PR #${current.snapshot.number} moved to ${destinationBaseSha}, expected ${current.destinationBaseSha}` + ); + } + + current = + current.parentNumber === null + ? null + : entriesByNumber.get(current.parentNumber); + if (current === undefined) { + throw new Error( + `missing source stack parent for PR #${entry.snapshot.number}` + ); + } + } +} + +/** Runs one named GitHub write only after its live-reference guard succeeds. */ +export async function guardedMigrationWrite(phase, verifyReferences, write) { + if (!MIGRATION_WRITE_PHASES.includes(phase)) { + throw new Error(`unknown migration write phase ${phase}`); + } + await verifyReferences(phase); + return await write(); +} + +/** Classifies a NUL-delimited `git diff-tree --name-status` result. */ +export function classifyNameStatus(fields, includePath = isMigratedPath) { + const included = []; + const excluded = []; + for (let index = 0; index < fields.length; ) { + const status = fields[index++]; + if (!status) { + continue; + } + const pathCount = status.startsWith("R") || status.startsWith("C") ? 2 : 1; + const paths = fields.slice(index, index + pathCount); + index += pathCount; + if (paths.length !== pathCount || paths.some((path) => !path)) { + throw new Error("malformed git name-status output"); + } + if (status.startsWith("R") || status.startsWith("C")) { + throw new Error( + `rename/copy edge case (${status}: ${paths.join(" -> ")})` + ); + } + if (status === "T") { + throw new Error(`file-type change edge case (${paths.join(", ")})`); + } + if (!["A", "M", "D"].includes(status)) { + throw new Error(`unsupported git change status ${status}`); + } + for (const path of paths) { + if (path.startsWith("/") || path.split("/").includes("..")) { + throw new Error(`unsafe changed path ${JSON.stringify(path)}`); + } + (includePath(path) ? included : excluded).push(path); + } + } + return { + included: [...new Set(included)].sort(compareCodeUnits), + excluded: [...new Set(excluded)].sort(compareCodeUnits), + }; +} + +export function stableJson(value) { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort(compareCodeUnits) + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function digest(value) { + const content = + typeof value === "string" || ArrayBuffer.isView(value) + ? value + : stableJson(value); + return createHash("sha256").update(content).digest("hex"); +} + +export function migrationMarker(entry) { + return ``; +} + +/** Builds the exact migration-owned destination pull request body. */ +export function migrationPullBody(entry) { + const author = entry.snapshot.author + ? `@${entry.snapshot.author}` + : "the original author"; + return `${entry.snapshot.body}\n\n---\nMigrated from [${SOURCE_REPOSITORY}#${entry.snapshot.number}](${entry.snapshot.url}) after the CLI subtree import. Original author: ${author}.\n\n${migrationMarker(entry)}`; +} + +function sameValues(left, right) { + return stableJson(left) === stableJson(right); +} + +function destinationPullMetadata(pull) { + return { + labels: names(pull.labels), + assignees: names(pull.assignees), + milestone: pull.milestone?.number ?? null, + reviewers: names(pull.requested_reviewers), + reviewerTeams: (pull.requested_teams ?? []) + .map((team) => team.slug) + .sort(compareCodeUnits), + }; +} + +/** + * Returns the exact migration phase of an existing destination pull request. + * Unknown or user-edited metadata returns null and must fail closed. + */ +export function destinationPullResumeState(entry, pull) { + if ( + pull?.state !== "open" || + pull.base.ref !== entry.destinationBase || + pull.base.sha !== entry.destinationBaseSha || + pull.head.ref !== entry.destinationBranch || + pull.head.repo?.full_name !== DESTINATION_REPOSITORY || + pull.head.sha !== entry.headSha || + pull.title !== entry.snapshot.title || + String(pull.body ?? "") !== migrationPullBody(entry) || + Boolean(pull.draft) !== entry.snapshot.draft + ) { + return null; + } + + const actual = destinationPullMetadata(pull); + const planned = entry.destinationMetadata; + const emptyIssueMetadata = { labels: [], assignees: [], milestone: null }; + const actualIssueMetadata = { + labels: actual.labels, + assignees: actual.assignees, + milestone: actual.milestone, + }; + const plannedIssueMetadata = { + labels: planned.labels, + assignees: planned.assignees, + milestone: planned.milestone, + }; + const emptyReviewers = { reviewers: [], reviewerTeams: [] }; + const actualReviewers = { + reviewers: actual.reviewers, + reviewerTeams: actual.reviewerTeams, + }; + const plannedReviewers = { + reviewers: planned.reviewers, + reviewerTeams: planned.reviewerTeams, + }; + + if ( + sameValues(actualIssueMetadata, plannedIssueMetadata) && + sameValues(actualReviewers, plannedReviewers) + ) { + return "complete"; + } + if ( + sameValues(actualIssueMetadata, plannedIssueMetadata) && + sameValues(actualReviewers, emptyReviewers) + ) { + return "issue-metadata"; + } + if ( + sameValues(actualIssueMetadata, emptyIssueMetadata) && + sameValues(actualReviewers, emptyReviewers) + ) { + return "created"; + } + return null; +} diff --git a/scripts/migrate-open-prs.mjs b/scripts/migrate-open-prs.mjs new file mode 100644 index 000000000..c88782f77 --- /dev/null +++ b/scripts/migrate-open-prs.mjs @@ -0,0 +1,1124 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildMappedManifest, + buildStackPlan, + classifyNameStatus, + createLegacyPathMapping, + DEFAULT_PLAN, + DESTINATION_REPOSITORY, + destinationPullResumeState, + destinationRecoveryAction, + digest, + FILTERED_TIPS, + guardedMigrationWrite, + isMigratedPath, + LEGACY_PATH_MOVES, + MIGRATED_PATHS, + mapSourcePath, + migrationPullBody, + parseArgs, + reconstructionMode, + replayParent, + SOURCE_LAYOUT_COMMIT, + SOURCE_MAIN_CUTOFF, + SOURCE_REPOSITORY, + snapshotPull, + stableJson, + verifyWriteReferences, +} from "./lib/pr-migration-plan.mjs"; + +const PLAN_VERSION = 2; +const SOURCE_MAIN_REF = "refs/migration/source-main"; + +function usage() { + return `Usage: + node scripts/migrate-open-prs.mjs [--output PATH] + node scripts/migrate-open-prs.mjs --execute [--plan PATH] + +The default command only reads GitHub/Git data, reconstructs every open PR in a +temporary clone, verifies its scoped diff, and exports a plan. It never pushes, +creates, edits, comments on, readies, or closes a PR. + +Options: + --output PATH Dry-run plan output (default: ${DEFAULT_PLAN}) + --execute Apply a previously exported and revalidated plan + --plan PATH Plan consumed by --execute (default: ${DEFAULT_PLAN}) + --keep-temp Keep the temporary reconstruction clone for inspection + --help Show this help +`; +} + +function command(program, args, options = {}) { + const result = spawnSync(program, args, { + cwd: options.cwd, + input: options.input, + encoding: options.encoding ?? "utf8", + env: options.env ? { ...process.env, ...options.env } : process.env, + maxBuffer: 256 * 1024 * 1024, + shell: false, + }); + if (result.error) throw result.error; + if (result.status !== 0 && !options.allowFailure) { + throw new Error( + `${program} ${args.join(" ")} failed (${result.status}):\n${String(result.stderr).trim()}` + ); + } + return result; +} + +function ghJson(args, input) { + const result = command( + "gh", + args, + input === undefined ? {} : { input: JSON.stringify(input) } + ); + const output = String(result.stdout).trim(); + return output ? JSON.parse(output) : null; +} + +function api(endpoint, options = {}) { + const args = ["api", endpoint]; + if (options.method) args.push("--method", options.method); + if (options.paginate) args.push("--paginate", "--slurp"); + if (options.input !== undefined) args.push("--input", "-"); + return ghJson(args, options.input); +} + +function git(cwd, args, options = {}) { + return command("git", args, { cwd, ...options }); +} + +function repositoryUrl(repository) { + return `https://github.com/${repository}.git`; +} + +async function listSourceSnapshots() { + const pages = api( + `repos/${SOURCE_REPOSITORY}/pulls?state=open&per_page=100`, + { paginate: true } + ); + return pages + .flat() + .map(snapshotPull) + .sort((left, right) => left.number - right.number); +} + +async function verifySnapshot(expected) { + const actual = snapshotPull( + api(`repos/${SOURCE_REPOSITORY}/pulls/${expected.number}`) + ); + if (stableJson(actual) !== stableJson(expected)) { + throw new Error( + `source PR #${expected.number} moved since the plan was exported` + ); + } +} + +function splitNul(buffer) { + return buffer.toString("utf8").split("\0").filter(Boolean); +} + +function assertCommit(repository, revision, description) { + const result = git(repository, ["cat-file", "-e", `${revision}^{commit}`], { + allowFailure: true, + }); + if (result.status !== 0) + throw new Error(`${description} ${revision} is not available as a commit`); +} + +function isAncestor(repository, ancestor, descendant) { + return ( + git(repository, ["merge-base", "--is-ancestor", ancestor, descendant], { + allowFailure: true, + }).status === 0 + ); +} + +function pathExists(repository, revision, path) { + return ( + git(repository, ["cat-file", "-e", `${revision}:${path}`], { + allowFailure: true, + }).status === 0 + ); +} + +function fetchSourcePull(repository, entry) { + const ref = `refs/migration/source/${entry.snapshot.number}`; + git(repository, [ + "fetch", + "--no-tags", + repositoryUrl(SOURCE_REPOSITORY), + `+refs/pull/${entry.snapshot.number}/head:${ref}`, + ]); + const fetched = git(repository, ["rev-parse", ref]).stdout.trim(); + if (fetched !== entry.snapshot.head.sha) { + throw new Error( + `source PR #${entry.snapshot.number} head resolved to ${fetched}, expected ${entry.snapshot.head.sha}` + ); + } + return ref; +} + +function inspectCommit(repository, parent, commit, layout, legacyMapping) { + const status = git( + repository, + [ + "diff-tree", + "--no-commit-id", + "--name-status", + "-r", + "-z", + "--find-renames", + "--find-copies", + parent, + commit, + ], + { encoding: "buffer" } + ); + const classified = classifyNameStatus(splitNul(status.stdout), (path) => + Boolean(mapSourcePath(path, layout, legacyMapping)) + ); + const numstat = splitNul( + git(repository, ["diff", "--numstat", "-z", parent, commit], { + encoding: "buffer", + }).stdout + ); + for (const record of numstat) { + const [added, deleted, path] = record.split("\t"); + if ((added === "-" || deleted === "-") && path) + throw new Error(`binary change in ${path} at ${commit}`); + } + for (const path of classified.included) { + for (const revision of [parent, commit]) { + const tree = git(repository, ["ls-tree", revision, "--", path]).stdout; + if (tree.startsWith("120000 ") || tree.startsWith("160000 ")) { + throw new Error( + `symlink or submodule edge case at ${path} in ${commit}` + ); + } + } + } + return classified; +} + +function commitMetadata(repository, commit) { + const fields = git( + repository, + [ + "show", + "-s", + "--format=%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B%x00", + commit, + ], + { + encoding: "buffer", + } + ) + .stdout.toString("utf8") + .split("\0"); + if (fields.length < 7) + throw new Error(`could not read metadata for ${commit}`); + return { + authorName: fields[0], + authorEmail: fields[1], + authorDate: fields[2], + committerName: fields[3], + committerEmail: fields[4], + committerDate: fields[5], + message: fields[6], + }; +} + +function scopedPatch(repository, from, to) { + return git( + repository, + [ + "diff", + "--binary", + "--full-index", + "--no-renames", + from, + to, + "--", + ...MIGRATED_PATHS, + ], + { encoding: "buffer" } + ).stdout; +} + +function sourceLayout(repository, baseRevision) { + if (isAncestor(repository, SOURCE_LAYOUT_COMMIT, baseRevision)) { + return "monorepo"; + } + if (isAncestor(repository, baseRevision, SOURCE_LAYOUT_COMMIT)) + return "legacy"; + throw new Error( + `source merge base ${baseRevision} is not comparable with layout commit ${SOURCE_LAYOUT_COMMIT}` + ); +} + +function deriveLegacyMapping(repository) { + for (const [source, destination] of LEGACY_PATH_MOVES) { + if ( + !pathExists(repository, `${SOURCE_LAYOUT_COMMIT}^`, source) || + pathExists(repository, SOURCE_LAYOUT_COMMIT, source) || + !pathExists(repository, SOURCE_LAYOUT_COMMIT, destination) + ) { + throw new Error( + `legacy path mapping ${source} -> ${destination} does not match layout commit ${SOURCE_LAYOUT_COMMIT}` + ); + } + } + return createLegacyPathMapping(); +} + +function mappedSourcePatch(repository, from, to, layout, legacyMapping) { + if (layout === "monorepo") return scopedPatch(repository, from, to); + const cliPatch = git( + repository, + [ + "diff", + "--binary", + "--full-index", + "--no-renames", + "--src-prefix=a/packages/cli/", + "--dst-prefix=b/packages/cli/", + from, + to, + "--", + ...legacyMapping.cliRoots, + ], + { encoding: "buffer" } + ).stdout; + const docsPatch = + legacyMapping.docsRoots.size > 0 + ? git( + repository, + [ + "diff", + "--binary", + "--full-index", + "--no-renames", + "--relative=docs", + "--src-prefix=a/apps/cli-docs/", + "--dst-prefix=b/apps/cli-docs/", + from, + to, + "--", + ...[...legacyMapping.docsRoots].map((root) => `docs/${root}`), + ], + { encoding: "buffer" } + ).stdout + : Buffer.alloc(0); + return Buffer.concat([docsPatch, cliPatch]); +} + +function changeManifest(repository, from, to, mapPath) { + const fields = splitNul( + git(repository, ["diff", "--name-status", "-z", "--no-renames", from, to], { + encoding: "buffer", + }).stdout + ); + const changes = []; + for (let index = 0; index < fields.length; ) { + const status = fields[index++]; + const path = fields[index++]; + if (!(status && path && ["A", "M", "D"].includes(status))) { + throw new Error("unsupported change while building exact diff manifest"); + } + const identity = + status === "D" + ? { blob: null, mode: null } + : (() => { + const record = git(repository, ["ls-tree", "-z", to, "--", path]) + .stdout.toString("utf8") + .replace(/\0$/, ""); + const match = /^(\d+) blob ([0-9a-f]+)\t/.exec(record); + if (!match) { + throw new Error( + `could not read regular-file identity for ${path}` + ); + } + return { blob: match[2], mode: match[1] }; + })(); + changes.push({ ...identity, path, status }); + } + return buildMappedManifest(changes, mapPath); +} + +function destinationDiffDigest(repository, from, to) { + return digest( + changeManifest(repository, from, to, (path) => + isMigratedPath(path) ? path : null + ) + ); +} + +function createCommit(repository, commit) { + const metadata = commitMetadata(repository, commit); + git( + repository, + ["commit", "--no-gpg-sign", "--allow-empty-message", "-F", "-"], + { + input: metadata.message, + env: { + GIT_AUTHOR_NAME: metadata.authorName, + GIT_AUTHOR_EMAIL: metadata.authorEmail, + GIT_AUTHOR_DATE: metadata.authorDate, + GIT_COMMITTER_NAME: metadata.committerName, + GIT_COMMITTER_EMAIL: metadata.committerEmail, + GIT_COMMITTER_DATE: metadata.committerDate, + }, + } + ); +} + +function reconstructEntry(repository, entry, sourceRef, legacyMapping) { + const targetRevision = + entry.parentNumber === null + ? SOURCE_MAIN_REF + : `refs/migration/source/${entry.parentNumber}`; + assertCommit( + repository, + targetRevision, + `source base for PR #${entry.snapshot.number}` + ); + const sourceTargetSha = git(repository, [ + "rev-parse", + targetRevision, + ]).stdout.trim(); + const baseRevision = git(repository, [ + "merge-base", + targetRevision, + sourceRef, + ]).stdout.trim(); + assertCommit( + repository, + baseRevision, + `source merge base for PR #${entry.snapshot.number}` + ); + const layout = sourceLayout(repository, baseRevision); + const sourceClassification = inspectCommit( + repository, + baseRevision, + sourceRef, + layout, + legacyMapping + ); + + git(repository, ["checkout", "--detach", entry.destinationBase]); + git(repository, ["checkout", "-B", entry.destinationBranch]); + const destinationBaseSha = git(repository, [ + "rev-parse", + entry.destinationBase, + ]).stdout.trim(); + const commits = git(repository, [ + "rev-list", + "--reverse", + "--first-parent", + `${baseRevision}..${sourceRef}`, + ]) + .stdout.trim() + .split("\n") + .filter(Boolean); + const parentCounts = commits.map((commit) => { + const parents = git(repository, [ + "rev-list", + "--parents", + "-n", + "1", + commit, + ]) + .stdout.trim() + .split(" ") + .slice(1); + return parents.length; + }); + const hasMerge = reconstructionMode(parentCounts) === "net"; + const replayCommits = hasMerge ? [sourceRef] : commits; + for (const commit of replayCommits) { + const parents = git(repository, [ + "rev-list", + "--parents", + "-n", + "1", + commit, + ]) + .stdout.trim() + .split(" ") + .slice(1); + const parent = hasMerge ? baseRevision : replayParent(parents, commit); + const classified = inspectCommit( + repository, + parent, + commit, + layout, + legacyMapping + ); + if (classified.included.length === 0) continue; + const patch = mappedSourcePatch( + repository, + parent, + commit, + layout, + legacyMapping + ); + git(repository, ["apply", "--index", "--whitespace=nowarn", "-"], { + input: patch, + encoding: "buffer", + }); + createCommit(repository, commit); + } + + if (sourceClassification.included.length === 0) { + return { + action: "skip", + reason: "no changes under imported CLI or CLI docs paths", + destinationBaseSha, + includedPaths: [], + mappedPaths: [], + excludedPaths: sourceClassification.excluded, + sourceLayout: layout, + sourceMergeBaseSha: baseRevision, + sourceTargetSha, + }; + } + const sourceManifest = changeManifest( + repository, + baseRevision, + sourceRef, + (path) => mapSourcePath(path, layout, legacyMapping) + ); + const destinationManifest = changeManifest( + repository, + entry.destinationBase, + entry.destinationBranch, + (path) => (isMigratedPath(path) ? path : null) + ); + const sourceDiffSha256 = digest(sourceManifest); + const destinationDiffSha256 = digest(destinationManifest); + if (sourceDiffSha256 !== destinationDiffSha256) { + throw new Error( + `exact scoped diff verification failed for PR #${entry.snapshot.number}` + ); + } + return { + action: "migrate", + destinationBaseSha, + headSha: git(repository, [ + "rev-parse", + entry.destinationBranch, + ]).stdout.trim(), + diffSha256: destinationDiffSha256, + includedPaths: sourceClassification.included, + mappedPaths: sourceClassification.included + .map((path) => { + const mapped = mapSourcePath(path, layout, legacyMapping); + if (!mapped) throw new Error(`included path ${path} has no mapping`); + return mapped; + }) + .sort(), + excludedPaths: sourceClassification.excluded, + sourceDiffSha256, + sourceLayout: layout, + sourceMergeBaseSha: baseRevision, + sourceTargetSha, + }; +} + +function isNotFound(result) { + return ( + result.status !== 0 && + /(?:HTTP\s+404|status code 404|Not Found)/i.test(String(result.stderr)) + ); +} + +function verifyDestinationBranch(repository, entry, branchSha) { + if (branchSha !== entry.headSha) return false; + git(repository, [ + "fetch", + "--no-tags", + "origin", + `refs/heads/${entry.destinationBranch}`, + ]); + const fetchedSha = git(repository, ["rev-parse", "FETCH_HEAD"]).stdout.trim(); + if (fetchedSha !== branchSha) return false; + return ( + destinationDiffDigest(repository, entry.destinationBase, "FETCH_HEAD") === + entry.diffSha256 + ); +} + +function checkDestinationCollision(repository, entry) { + if (entry.action === "skip") return; + const refResult = command( + "gh", + [ + "api", + `repos/${DESTINATION_REPOSITORY}/git/ref/heads/${encodeURIComponent(entry.destinationBranch)}`, + ], + { allowFailure: true } + ); + const existingPull = findDestinationPull(entry); + if (refResult.status !== 0) { + if (!isNotFound(refResult)) { + throw new Error( + `could not determine whether destination branch ${entry.destinationBranch} exists: ${String(refResult.stderr).trim()}` + ); + } + if (existingPull) + throw new Error( + `destination PR collision for ${entry.destinationBranch}` + ); + return; + } + const branchSha = JSON.parse(refResult.stdout).object.sha; + const diffMatches = verifyDestinationBranch(repository, entry, branchSha); + const action = destinationRecoveryAction({ + branchExists: true, + branchSha, + diffMatches, + expectedHeadSha: entry.headSha, + pullExists: Boolean(existingPull), + }); + if ( + action === "reject" || + (existingPull && destinationPullResumeState(entry, existingPull) === null) + ) { + throw new Error( + `destination branch/PR collision for ${entry.destinationBranch}` + ); + } +} + +async function buildPlan(workspace, snapshots) { + command( + "gh", + ["repo", "clone", DESTINATION_REPOSITORY, "repository", "--", "--no-tags"], + { cwd: workspace } + ); + const repository = join(workspace, "repository"); + const originUrl = git(repository, [ + "remote", + "get-url", + "origin", + ]).stdout.trim(); + if (!/(?:[:/])getsentry\/sentry-mcp(?:\.git)?$/.test(originUrl)) { + throw new Error(`destination clone has unexpected origin ${originUrl}`); + } + git(repository, ["config", "commit.gpgsign", "false"]); + git(repository, [ + "fetch", + "--no-tags", + repositoryUrl(SOURCE_REPOSITORY), + `+refs/heads/main:${SOURCE_MAIN_REF}`, + SOURCE_MAIN_CUTOFF, + ]); + assertCommit(repository, SOURCE_MAIN_CUTOFF, "source cutoff"); + assertCommit(repository, SOURCE_MAIN_REF, "source main"); + assertCommit(repository, SOURCE_LAYOUT_COMMIT, "source layout commit"); + if (!isAncestor(repository, SOURCE_LAYOUT_COMMIT, SOURCE_MAIN_CUTOFF)) { + throw new Error( + "source layout commit is not an ancestor of the source cutoff" + ); + } + const legacyMapping = deriveLegacyMapping(repository); + if (legacyMapping.cliRoots.size === 0 || legacyMapping.docsRoots.size === 0) { + throw new Error( + "could not derive legacy CLI and docs lineage from the source layout commit" + ); + } + if (scopedPatch(repository, SOURCE_MAIN_CUTOFF, SOURCE_MAIN_REF).length > 0) { + throw new Error( + "source main contains CLI or CLI docs changes after the imported cutoff" + ); + } + for (const tip of FILTERED_TIPS) { + assertCommit(repository, tip, "filtered import tip"); + if (!isAncestor(repository, tip, "origin/main")) + throw new Error(`filtered import tip ${tip} is not in destination main`); + } + + const entries = buildStackPlan(snapshots); + for (const entry of entries) fetchSourcePull(repository, entry); + const entriesByNumber = new Map( + entries.map((entry) => [entry.snapshot.number, entry]) + ); + for (const entry of entries) { + if (entry.parentNumber !== null) { + const parent = entriesByNumber.get(entry.parentNumber); + if (!parent?.action) { + throw new Error( + `source stack parent was not reconstructed for PR #${entry.snapshot.number}` + ); + } + entry.destinationBase = + parent.action === "migrate" + ? parent.destinationBranch + : parent.destinationBase; + } + Object.assign( + entry, + reconstructEntry( + repository, + entry, + `refs/migration/source/${entry.snapshot.number}`, + legacyMapping + ) + ); + } + const metadata = destinationMetadata(); + for (const entry of entries) { + entry.destinationMetadata = + entry.action === "skip" + ? null + : { + labels: entry.snapshot.labels.filter((label) => + metadata.labels.has(label) + ), + assignees: entry.snapshot.assignees.filter(isValidAssignee), + reviewers: entry.snapshot.reviewers.filter( + (reviewer) => + reviewer !== metadata.viewer && isValidReviewer(reviewer) + ), + reviewerTeams: entry.snapshot.reviewerTeams.filter(isValidTeam), + milestone: entry.snapshot.milestone + ? (metadata.milestones.get(entry.snapshot.milestone) ?? null) + : null, + }; + } + for (const entry of entries) checkDestinationCollision(repository, entry); + return { + version: PLAN_VERSION, + createdAt: new Date().toISOString(), + sourceRepository: SOURCE_REPOSITORY, + destinationRepository: DESTINATION_REPOSITORY, + sourceMainCutoff: SOURCE_MAIN_CUTOFF, + filteredTips: FILTERED_TIPS, + entries, + }; +} + +function validatePlanIdentity(expected, rebuilt) { + const omitCreatedAt = ({ createdAt: _createdAt, ...plan }) => plan; + if ( + stableJson(omitCreatedAt(expected)) !== stableJson(omitCreatedAt(rebuilt)) + ) { + throw new Error( + "rebuilt plan differs from exported plan; export a new dry-run plan before executing" + ); + } +} + +function destinationMetadata() { + const viewer = api("user").login; + const labelPages = api( + `repos/${DESTINATION_REPOSITORY}/labels?per_page=100`, + { paginate: true } + ); + const milestonePages = api( + `repos/${DESTINATION_REPOSITORY}/milestones?state=open&per_page=100`, + { paginate: true } + ); + return { + viewer, + labels: new Set(labelPages.flat().map((label) => label.name)), + milestones: new Map( + milestonePages + .flat() + .map((milestone) => [milestone.title, milestone.number]) + ), + }; +} + +function isValidAssignee(login) { + const result = command( + "gh", + [ + "api", + `repos/${DESTINATION_REPOSITORY}/assignees/${encodeURIComponent(login)}`, + "--silent", + ], + { allowFailure: true } + ); + if (result.status === 0) return true; + if (isNotFound(result)) return false; + throw new Error( + `could not validate destination assignee ${login}: ${String(result.stderr).trim()}` + ); +} + +function isValidReviewer(login) { + const result = command( + "gh", + [ + "api", + `repos/${DESTINATION_REPOSITORY}/collaborators/${encodeURIComponent(login)}/permission`, + ], + { allowFailure: true } + ); + if (result.status !== 0) { + if (isNotFound(result)) return false; + throw new Error( + `could not validate destination reviewer ${login}: ${String(result.stderr).trim()}` + ); + } + const permission = JSON.parse(result.stdout).permission; + return permission && permission !== "none"; +} + +function isValidTeam(team) { + const [owner, repository] = DESTINATION_REPOSITORY.split("/"); + const result = command( + "gh", + [ + "api", + `orgs/${owner}/teams/${encodeURIComponent(team)}/repos/${owner}/${repository}`, + "--silent", + ], + { allowFailure: true } + ); + if (result.status === 0) return true; + if (isNotFound(result)) return false; + throw new Error( + `could not validate destination reviewer team ${team}: ${String(result.stderr).trim()}` + ); +} + +async function verifyRemotePull(repository, entry, number) { + const pull = api(`repos/${DESTINATION_REPOSITORY}/pulls/${number}`); + const resumeState = destinationPullResumeState(entry, pull); + if (resumeState === null) { + throw new Error( + `destination PR #${number} does not have the exact planned head, base, or migration-owned metadata` + ); + } + git(repository, [ + "fetch", + "--no-tags", + "origin", + `refs/heads/${entry.destinationBranch}`, + ]); + const remoteSha = git(repository, ["rev-parse", "FETCH_HEAD"]).stdout.trim(); + if (remoteSha !== entry.headSha) + throw new Error( + `destination branch ${entry.destinationBranch} moved after push` + ); + if ( + destinationDiffDigest(repository, entry.destinationBase, remoteSha) !== + entry.diffSha256 + ) + throw new Error( + `destination PR #${number} remote diff does not match the plan` + ); + return { pull, resumeState }; +} + +function findDestinationPull(entry) { + const [owner] = DESTINATION_REPOSITORY.split("/"); + const pulls = api( + `repos/${DESTINATION_REPOSITORY}/pulls?state=all&head=${owner}:${encodeURIComponent(entry.destinationBranch)}&per_page=10` + ); + const match = pulls[0]; + return match + ? api(`repos/${DESTINATION_REPOSITORY}/pulls/${match.number}`) + : null; +} + +function sourceAlreadyLinked(entry, destinationUrl) { + const pages = api( + `repos/${SOURCE_REPOSITORY}/issues/${entry.snapshot.number}/comments?per_page=100`, + { paginate: true } + ); + return pages + .flat() + .some((comment) => String(comment.body ?? "").includes(destinationUrl)); +} + +async function verifySourceChain(entry, entriesByNumber) { + const visited = new Set(); + for (let current = entry; current; ) { + if (visited.has(current.snapshot.number)) { + throw new Error( + `cycle while verifying source stack for PR #${entry.snapshot.number}` + ); + } + visited.add(current.snapshot.number); + await verifySnapshot(current.snapshot); + current = + current.parentNumber === null + ? null + : entriesByNumber.get(current.parentNumber); + if (current === undefined) { + throw new Error( + `missing source stack parent for PR #${entry.snapshot.number}` + ); + } + } +} + +function fetchWriteReference(repository, remote, remoteRef, localRef) { + git(repository, ["fetch", "--no-tags", remote, `+${remoteRef}:${localRef}`]); + return git(repository, ["rev-parse", localRef]).stdout.trim(); +} + +async function verifyLiveWriteReferences(repository, entry, entriesByNumber) { + await verifySourceChain(entry, entriesByNumber); + await verifyWriteReferences( + entry, + entriesByNumber, + (current) => + fetchWriteReference( + repository, + repositoryUrl(current.snapshot.base.repository), + `refs/heads/${current.snapshot.base.branch}`, + `refs/migration/write/source-target/${current.snapshot.number}` + ), + (current) => + fetchWriteReference( + repository, + "origin", + `refs/heads/${current.destinationBase}`, + `refs/migration/write/destination-base/${current.snapshot.number}` + ) + ); +} + +async function writeWithFreshReferences( + phase, + repository, + entry, + entriesByNumber, + write +) { + return await guardedMigrationWrite( + phase, + async () => verifyLiveWriteReferences(repository, entry, entriesByNumber), + write + ); +} + +async function executePlan(repository, plan) { + const entriesByNumber = new Map( + plan.entries.map((entry) => [entry.snapshot.number, entry]) + ); + for (const entry of plan.entries) { + if (entry.action === "skip") { + console.log(`skip #${entry.snapshot.number}: ${entry.reason}`); + continue; + } + await verifySourceChain(entry, entriesByNumber); + const refResult = command( + "gh", + [ + "api", + `repos/${DESTINATION_REPOSITORY}/git/ref/heads/${encodeURIComponent(entry.destinationBranch)}`, + ], + { allowFailure: true } + ); + const existingPull = findDestinationPull(entry); + let recoveryAction; + if (refResult.status === 0) { + const branchSha = JSON.parse(refResult.stdout).object.sha; + recoveryAction = destinationRecoveryAction({ + branchExists: true, + branchSha, + diffMatches: verifyDestinationBranch(repository, entry, branchSha), + expectedHeadSha: entry.headSha, + pullExists: Boolean(existingPull), + }); + if ( + recoveryAction === "reject" || + (existingPull && + destinationPullResumeState(entry, existingPull) === null) + ) { + throw new Error( + `destination branch/PR collision for ${entry.destinationBranch}` + ); + } + } else { + if (!isNotFound(refResult)) { + throw new Error( + `could not determine whether destination branch ${entry.destinationBranch} exists: ${String(refResult.stderr).trim()}` + ); + } + if (existingPull) + throw new Error( + `destination PR collision for ${entry.destinationBranch}` + ); + recoveryAction = destinationRecoveryAction({ + branchExists: false, + branchSha: null, + diffMatches: false, + expectedHeadSha: entry.headSha, + pullExists: false, + }); + await writeWithFreshReferences( + "push-branch", + repository, + entry, + entriesByNumber, + () => + git(repository, [ + "push", + "--atomic", + `--force-with-lease=refs/heads/${entry.destinationBranch}:`, + "origin", + `${entry.headSha}:refs/heads/${entry.destinationBranch}`, + ]) + ); + if (!verifyDestinationBranch(repository, entry, entry.headSha)) { + throw new Error( + `pushed destination branch ${entry.destinationBranch} does not match the plan` + ); + } + } + + let pull = existingPull; + if ( + !pull && + (recoveryAction === "push" || recoveryAction === "create-pull") + ) { + const createArgs = [ + "pr", + "create", + "--repo", + DESTINATION_REPOSITORY, + "--head", + entry.destinationBranch, + "--base", + entry.destinationBase, + "--title", + entry.snapshot.title, + "--body", + migrationPullBody(entry), + ]; + if (entry.snapshot.draft) createArgs.push("--draft"); + const result = await writeWithFreshReferences( + "create-pull", + repository, + entry, + entriesByNumber, + () => command("gh", createArgs) + ); + const number = Number(result.stdout.trim().match(/\/(\d+)\/?$/)?.[1]); + if (!Number.isInteger(number)) + throw new Error( + `could not parse created PR URL: ${result.stdout.trim()}` + ); + pull = api(`repos/${DESTINATION_REPOSITORY}/pulls/${number}`); + } + + let verified = await verifyRemotePull(repository, entry, pull.number); + if (verified.resumeState === "created") { + await writeWithFreshReferences( + "update-metadata", + repository, + entry, + entriesByNumber, + () => + api(`repos/${DESTINATION_REPOSITORY}/issues/${pull.number}`, { + method: "PATCH", + input: { + labels: entry.destinationMetadata.labels, + assignees: entry.destinationMetadata.assignees, + milestone: entry.destinationMetadata.milestone, + }, + }) + ); + verified = await verifyRemotePull(repository, entry, pull.number); + } + const reviewers = entry.destinationMetadata.reviewers; + const teamReviewers = entry.destinationMetadata.reviewerTeams; + if ( + verified.resumeState === "issue-metadata" && + (reviewers.length > 0 || teamReviewers.length > 0) + ) { + await writeWithFreshReferences( + "request-reviewers", + repository, + entry, + entriesByNumber, + () => + api( + `repos/${DESTINATION_REPOSITORY}/pulls/${pull.number}/requested_reviewers`, + { + method: "POST", + input: { reviewers, team_reviewers: teamReviewers }, + } + ) + ); + verified = await verifyRemotePull(repository, entry, pull.number); + } + if (verified.resumeState !== "complete") { + throw new Error( + `destination PR #${pull.number} metadata did not reach the planned state` + ); + } + + await verifyRemotePull(repository, entry, pull.number); + if (!sourceAlreadyLinked(entry, pull.html_url)) { + await writeWithFreshReferences( + "comment-source", + repository, + entry, + entriesByNumber, + () => + command("gh", [ + "pr", + "comment", + String(entry.snapshot.number), + "--repo", + SOURCE_REPOSITORY, + "--body", + `Migrated to ${pull.html_url} after exact base and scoped-diff verification.`, + ]) + ); + } + console.log(`migrated #${entry.snapshot.number} -> ${pull.html_url}`); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + command("gh", ["auth", "status"]); + command("git", ["--version"]); + const workspace = await mkdtemp(join(tmpdir(), "cli-pr-migration-")); + try { + const snapshots = await listSourceSnapshots(); + const rebuilt = await buildPlan(workspace, snapshots); + if (!options.execute) { + await writeFile(options.output, `${JSON.stringify(rebuilt, null, 2)}\n`, { + flag: "wx", + }); + console.log( + `Exported read-only migration plan for ${rebuilt.entries.length} open PRs to ${options.output}` + ); + console.log(`Plan digest: ${digest(rebuilt)}`); + return; + } + const exported = JSON.parse(await readFile(options.plan, "utf8")); + validatePlanIdentity(exported, rebuilt); + await executePlan(join(workspace, "repository"), rebuilt); + } finally { + if (options.keepTemp) + console.error(`Temporary reconstruction kept at ${workspace}`); + else await rm(workspace, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error( + `error: ${error instanceof Error ? error.message : String(error)}` + ); + process.exitCode = 1; +}); diff --git a/scripts/migrate-open-prs.test.mjs b/scripts/migrate-open-prs.test.mjs new file mode 100644 index 000000000..bd0cbfb10 --- /dev/null +++ b/scripts/migrate-open-prs.test.mjs @@ -0,0 +1,536 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { + buildMappedManifest, + buildStackPlan, + classifyNameStatus, + classifySourcePaths, + createLegacyPathMapping, + destinationPullResumeState, + destinationRecoveryAction, + digest, + guardedMigrationWrite, + isMigratedPath, + MIGRATION_WRITE_PHASES, + mapSourcePath, + migrationPullBody, + parseArgs, + reconstructionMode, + replayParent, + snapshotPull, + verifyWriteReferences, +} from "./lib/pr-migration-plan.mjs"; + +const MOVED_REFERENCE_ERROR = /moved to/; + +test("imports every plan helper referenced by the executable", async () => { + const source = await readFile( + new URL("./migrate-open-prs.mjs", import.meta.url), + "utf8" + ); + const imports = source.match( + /import \{(?[\s\S]*?)\} from "\.\/lib\/pr-migration-plan\.mjs";/ + )?.groups?.names; + assert.ok(imports); + assert.match(imports, /\bisMigratedPath\b/); +}); + +function snapshot(number, head, base = "main", repository = "getsentry/cli") { + return { + number, + title: `PR ${number}`, + body: "", + author: "author", + state: "open", + draft: false, + head: { repository, branch: head, sha: `${number}-head` }, + base: { repository, branch: base, sha: `${number}-base` }, + labels: [], + assignees: [], + reviewers: [], + reviewerTeams: [], + milestone: null, + url: `https://github.com/getsentry/cli/pull/${number}`, + }; +} + +test("maps multiple stacks by exact repository and source base branch", () => { + const entries = buildStackPlan([ + snapshot(1588, "second", "first"), + snapshot(1572, "first"), + snapshot(1405, "c", "b"), + snapshot(1402, "a"), + snapshot(1404, "b", "a"), + ]); + assert.deepEqual( + entries.map((entry) => [ + entry.snapshot.number, + entry.parentNumber, + entry.destinationBase, + ]), + [ + [1402, null, "main"], + [1572, null, "main"], + [1404, 1402, "migrated/cli-pr-1402"], + [1588, 1572, "migrated/cli-pr-1572"], + [1405, 1404, "migrated/cli-pr-1404"], + ] + ); +}); + +test("accepts stale base SHAs for root and stacked pull requests", () => { + const parent = snapshot(10, "parent"); + parent.base.sha = "old-main"; + const child = snapshot(11, "child", "parent"); + child.base.sha = "old-parent-head"; + const entries = buildStackPlan([child, parent]); + assert.deepEqual( + entries.map((entry) => [entry.snapshot.number, entry.parentNumber]), + [ + [10, null], + [11, 10], + ] + ); +}); + +test("does not confuse equal branch names from forks", () => { + const parent = snapshot(1, "topic"); + parent.head.repository = "alice/cli"; + const child = snapshot(2, "child"); + child.base.branch = "topic"; + assert.throws( + () => buildStackPlan([parent, child]), + /neither main nor another open PR head/ + ); +}); + +test("rejects pull requests targeting another repository", () => { + const pull = snapshot(1, "topic"); + pull.base.repository = "alice/cli"; + assert.throws(() => buildStackPlan([pull]), /unexpected repository/); +}); + +test("rejects ambiguous heads and stack cycles", () => { + assert.throws( + () => buildStackPlan([snapshot(1, "same"), snapshot(2, "same")]), + /ambiguous/ + ); + assert.throws( + () => buildStackPlan([snapshot(1, "a", "b"), snapshot(2, "b", "a")]), + /cycle/ + ); +}); + +test("classifies only imported destination paths", () => { + assert.equal(isMigratedPath("packages/cli/src/index.ts"), true); + assert.equal(isMigratedPath("apps/cli-docs"), true); + assert.equal(isMigratedPath(".github/workflows/ci.yml"), false); + assert.deepEqual( + classifyNameStatus([ + "M", + "packages/cli/src/index.ts", + "A", + ".github/workflows/ci.yml", + ]), + { + included: ["packages/cli/src/index.ts"], + excluded: [".github/workflows/ci.yml"], + } + ); +}); + +test("maps only paths in the explicit historical layout move", () => { + const mapping = createLegacyPathMapping(); + assert.equal(mapping.cliRoots.has("src"), true); + assert.equal(mapping.cliRoots.has("test"), true); + assert.equal(mapping.docsRoots.has("src"), true); + assert.equal( + mapSourcePath("src/index.ts", "legacy", mapping), + "packages/cli/src/index.ts" + ); + assert.equal( + mapSourcePath("docs/src/index.mdx", "legacy", mapping), + "apps/cli-docs/src/index.mdx" + ); + assert.equal( + mapSourcePath(".github/workflows/ci.yml", "legacy", mapping), + null + ); + assert.equal(mapSourcePath(".gitignore", "legacy", mapping), null); + assert.equal(mapSourcePath("package.json", "legacy", mapping), null); + assert.equal(mapSourcePath("docs/pnpm-lock.yaml", "legacy", mapping), null); + assert.deepEqual( + classifySourcePaths( + [ + "src/index.ts", + "docs/package.json", + ".gitignore", + "package.json", + "docs/pnpm-lock.yaml", + ".github/workflows/ci.yml", + ], + "legacy", + mapping + ), + { + included: ["docs/package.json", "src/index.ts"], + excluded: [ + ".github/workflows/ci.yml", + ".gitignore", + "docs/pnpm-lock.yaml", + "package.json", + ], + mapped: ["apps/cli-docs/package.json", "packages/cli/src/index.ts"], + } + ); +}); + +test("does not infer lineage from a same-named destination root", () => { + const mapping = createLegacyPathMapping(); + assert.equal(mapping.cliRoots.has("admin"), false); + assert.equal(mapSourcePath("admin/config.ts", "legacy", mapping), null); +}); + +test("keeps monorepo paths unchanged and rejects unknown layouts", () => { + assert.equal( + mapSourcePath("packages/cli/src/index.ts", "monorepo"), + "packages/cli/src/index.ts" + ); + assert.equal(mapSourcePath("src/index.ts", "monorepo"), null); + assert.throws(() => mapSourcePath("src/index.ts", "future"), /unknown/); +}); + +test("replays a merge commit against its first parent", () => { + assert.equal(replayParent(["first", "merged"], "commit"), "first"); + assert.throws(() => replayParent([], "root"), /root commit/); + assert.equal(reconstructionMode([1, 1, 2, 1]), "net"); + assert.equal(reconstructionMode([1, 1]), "commits"); +}); + +test("verifies mapped changes by exact blob identity", () => { + const mapping = { + cliRoots: new Set(["src"]), + docsRoots: new Set(["a.md"]), + }; + const manifest = buildMappedManifest( + [ + { blob: "blob-b", mode: "100644", path: "src/b.ts", status: "M" }, + { + blob: "admin", + mode: "100644", + path: ".github/ci.yml", + status: "M", + }, + { blob: "blob-a", mode: "100755", path: "docs/a.md", status: "A" }, + ], + (path) => mapSourcePath(path, "legacy", mapping) + ); + assert.deepEqual(manifest, [ + { + blob: "blob-a", + mode: "100755", + path: "apps/cli-docs/a.md", + status: "A", + }, + { + blob: "blob-b", + mode: "100644", + path: "packages/cli/src/b.ts", + status: "M", + }, + ]); + assert.notEqual( + digest(manifest), + digest([ + { + blob: "changed", + mode: "100755", + path: "apps/cli-docs/a.md", + status: "A", + }, + { + blob: "blob-b", + mode: "100644", + path: "packages/cli/src/b.ts", + status: "M", + }, + ]) + ); +}); + +function destinationPull(entry, overrides = {}) { + return { + state: "open", + title: entry.snapshot.title, + body: migrationPullBody(entry), + draft: entry.snapshot.draft, + base: { ref: entry.destinationBase, sha: entry.destinationBaseSha }, + head: { + ref: entry.destinationBranch, + sha: entry.headSha, + repo: { full_name: "getsentry/sentry-mcp" }, + }, + labels: [], + assignees: [], + requested_reviewers: [], + requested_teams: [], + milestone: null, + ...overrides, + }; +} + +test("resumes only exact migration-owned destination pull metadata", () => { + const entry = { + snapshot: snapshot(42, "topic"), + destinationBase: "main", + destinationBaseSha: "base", + destinationBranch: "migrated/cli-pr-42", + headSha: "head", + destinationMetadata: { + labels: ["CLI", "bug"], + assignees: ["alice"], + milestone: 7, + reviewers: ["bob"], + reviewerTeams: ["cli"], + }, + }; + assert.equal( + destinationPullResumeState(entry, destinationPull(entry)), + "created" + ); + const issueMetadata = destinationPull(entry, { + labels: [{ name: "bug" }, { name: "CLI" }], + assignees: [{ login: "alice" }], + milestone: { number: 7 }, + }); + assert.equal( + destinationPullResumeState(entry, issueMetadata), + "issue-metadata" + ); + assert.equal( + destinationPullResumeState(entry, { + ...issueMetadata, + requested_reviewers: [{ login: "bob" }], + requested_teams: [{ slug: "cli" }], + }), + "complete" + ); + for (const edited of [ + { title: "user title" }, + { body: `${migrationPullBody(entry)}\nuser edit` }, + { draft: true }, + { labels: [{ name: "user-label" }] }, + { assignees: [{ login: "mallory" }] }, + { milestone: { number: 99 } }, + { requested_reviewers: [{ login: "mallory" }] }, + ]) { + assert.equal( + destinationPullResumeState(entry, destinationPull(entry, edited)), + null + ); + } +}); + +test("keeps branch-without-pull recovery independent of pull metadata", () => { + assert.equal( + destinationRecoveryAction({ + branchExists: true, + branchSha: "head", + diffMatches: true, + expectedHeadSha: "head", + pullExists: false, + }), + "create-pull" + ); +}); + +test("sorts manifests by code units without locale comparison", () => { + const originalLocaleCompare = String.prototype.localeCompare; + String.prototype.localeCompare = () => { + throw new Error("localeCompare must not be called"); + }; + try { + const manifest = buildMappedManifest( + [ + { blob: "lower", mode: "100644", path: "a", status: "M" }, + { blob: "upper", mode: "100644", path: "Z", status: "M" }, + { blob: "accent", mode: "100644", path: "ä", status: "M" }, + ], + (path) => path + ); + assert.deepEqual( + manifest.map((entry) => entry.path), + ["Z", "a", "ä"] + ); + } finally { + String.prototype.localeCompare = originalLocaleCompare; + } +}); + +test("recovers an exact branch without a pull request", () => { + assert.equal( + destinationRecoveryAction({ + branchExists: true, + branchSha: "head", + diffMatches: true, + expectedHeadSha: "head", + pullExists: false, + }), + "create-pull" + ); + assert.equal( + destinationRecoveryAction({ + branchExists: true, + branchSha: "other", + diffMatches: true, + expectedHeadSha: "head", + pullExists: false, + }), + "reject" + ); + assert.equal( + destinationRecoveryAction({ + branchExists: true, + branchSha: "head", + diffMatches: false, + expectedHeadSha: "head", + pullExists: false, + }), + "reject" + ); +}); + +test("removes source-closing arguments", () => { + assert.throws(() => parseArgs(["--close-source"]), /unknown argument/); + assert.deepEqual(parseArgs(["--execute", "--plan", "plan.json"]), { + execute: true, + keepTemp: false, + output: "cli-open-pr-migration-plan.json", + plan: "plan.json", + }); +}); + +test("fails closed on rename, copy, malformed, and unsafe paths", () => { + assert.throws(() => classifyNameStatus(["R100", "a", "b"]), /rename\/copy/); + assert.throws(() => classifyNameStatus(["C100", "a", "b"]), /rename\/copy/); + assert.throws(() => classifyNameStatus(["M"]), /malformed/); + assert.throws( + () => classifyNameStatus(["M", "packages/cli/../secret"]), + /unsafe/ + ); +}); + +test("normalizes and sorts all required pull metadata", () => { + const result = snapshotPull({ + number: 3, + state: "open", + title: "title", + body: null, + draft: true, + html_url: "url", + user: { login: "alice" }, + head: { ref: "topic", sha: "h", repo: { full_name: "alice/cli" } }, + base: { ref: "main", sha: "b", repo: { full_name: "getsentry/cli" } }, + labels: [{ name: "z" }, { name: "a" }], + assignees: [{ login: "bob" }], + requested_reviewers: [{ login: "dave" }, { login: "carol" }], + requested_teams: [{ slug: "cli" }], + milestone: { title: "M1" }, + }); + assert.deepEqual(result.labels, ["a", "z"]); + assert.deepEqual(result.reviewers, ["carol", "dave"]); + assert.equal(result.head.repository, "alice/cli"); + assert.equal(result.state, "open"); + assert.equal(result.body, ""); + assert.equal(result.milestone, "M1"); +}); + +test("plan digests are key-order independent", () => { + assert.equal(digest({ b: 2, a: 1 }), digest({ a: 1, b: 2 })); +}); + +function writeReferenceEntries() { + const root = { + snapshot: snapshot(10, "root"), + parentNumber: null, + action: "migrate", + sourceTargetSha: "source-main", + destinationBase: "main", + destinationBaseSha: "destination-main", + }; + const skipped = { + snapshot: snapshot(11, "skipped", "root"), + parentNumber: 10, + action: "skip", + sourceTargetSha: "root-head", + destinationBase: "migrated/cli-pr-10", + destinationBaseSha: "root-destination-head", + }; + const leaf = { + snapshot: snapshot(12, "leaf", "skipped"), + parentNumber: 11, + action: "migrate", + sourceTargetSha: "skipped-head", + destinationBase: "migrated/cli-pr-10", + destinationBaseSha: "root-destination-head", + }; + return { + leaf, + entriesByNumber: new Map([ + [10, root], + [11, skipped], + [12, leaf], + ]), + }; +} + +test("verifies live root, stacked, and skipped-parent refs before writes", async () => { + const { leaf, entriesByNumber } = writeReferenceEntries(); + const sourceResolutions = []; + const destinationResolutions = []; + await verifyWriteReferences( + leaf, + entriesByNumber, + (entry) => { + sourceResolutions.push(entry.snapshot.number); + return entry.sourceTargetSha; + }, + (entry) => { + destinationResolutions.push(entry.snapshot.number); + return entry.destinationBaseSha; + } + ); + assert.deepEqual(sourceResolutions, [12, 11, 10]); + assert.deepEqual(destinationResolutions, [12, 11, 10]); +}); + +for (const drift of ["source target", "destination base"]) { + test(`${drift} drift blocks every GitHub write phase`, async () => { + const { leaf, entriesByNumber } = writeReferenceEntries(); + const mutations = []; + for (const phase of MIGRATION_WRITE_PHASES) { + await assert.rejects( + guardedMigrationWrite( + phase, + () => + verifyWriteReferences( + leaf, + entriesByNumber, + (entry) => + drift === "source target" && entry.snapshot.number === 11 + ? "moved-source-target" + : entry.sourceTargetSha, + (entry) => + drift === "destination base" && entry.snapshot.number === 11 + ? "moved-destination-base" + : entry.destinationBaseSha + ), + () => mutations.push(phase) + ), + MOVED_REFERENCE_ERROR + ); + } + assert.deepEqual(mutations, []); + }); +}