Skip to main content

Cross-platform project migration

Bloom can export a project's SDLC state to a platform-neutral, versioned bundle and re-materialize it on a different VCS platform - the "export from GitHub, import into GitLab" story (M23-6). It works because Bloom's planning core is already platform-neutral: the MilestonePlan, TicketPlan, and coordination loop key everything by title, never by a platform id, so a project's plan survives renumbering by construction.

The bundle

bloom.migration serializes a project into a single JSON document (kind: "bloom.project-bundle", schema_version: 1) containing:

  • The plan-side neutral core - the PRD, the title-keyed MilestonePlan, and the TicketPlan whose title-keyed dependencies edges carry the project's dependency graph.
  • Live platform state, read through the neutral VCSProvider seam: milestones (title/description/state), issues (title/body/state/labels/milestone link), every label in use, and each open pull request as a head-state file snapshot.
  • Omissions - an explicit record of everything the export could not capture 1:1.

Serialization is deterministic (sorted keys, sorted collections): the same project state always produces byte-identical JSON, so bundles diff and hash cleanly. parse_bundle refuses a bundle from a different schema_version generation loudly; unknown fields inside a supported version are ignored, so a newer minor writer stays readable.

Using it

Service entry points (bloom.migration): export_project(provider, repo, prd=..., plan=..., tickets=...) -> ProjectBundle and import_project(provider, owner, name, bundle) -> MigrationImportReport, both over any VCSProvider (GitHub, GitLab, or the in-memory fake).

CLI:

export BLOOM_MIGRATE_TOKEN=<github token>
bloom migrate export --platform github --owner acme --repo rocket \
--out rocket.bundle.json --state run-state.json

export BLOOM_MIGRATE_TOKEN=<gitlab token>
bloom migrate import --platform gitlab --gitlab-url https://gitlab.example.com \
--owner megacorp --repo rocket --bundle rocket.bundle.json --report report.json

Tokens are read from an environment variable (--token-env, default BLOOM_MIGRATE_TOKEN) - never from an argument - so secrets stay out of shell history. --state optionally points at a run-state JSON file with prd / plan / tickets keys (the plan-side core lives in Bloom's run state, not on the platform); without it the bundle still carries the full live platform state and records the gap in its omissions.

What import guarantees

Importing a bundle creates the repository on the target and re-creates, in order:

  1. Labels - every label in use, with Bloom's coordination-label colors (status:*, type:*, …) restored from LABEL_DEFS.
  2. Milestones - by title, with description and open/closed state.
  3. Issues - title, body, state, labels (including the status:* coordination label, so the coordinator resumes exactly where the project left off), and milestone link. #N cross-references in issue bodies are renumbered to the target's issue numbers.
  4. Open pull requests - the base branch (created off the default branch when it is a non-default integration branch), the head branch with the PR's changed files snapshotted at the source head, the PR itself (body cross-references renumbered), and its labels.
  5. PRD.md - committed on the default branch via the same renderer the orchestrator uses.

Dependencies need no re-linking: Ticket.dependencies is title-keyed, so the edges remain valid on the target as long as every referenced issue was imported - the import verifies each edge and reports dangling ones.

The MigrationImportReport also returns the source-to-target number mappings (issue_numbers, pull_request_numbers) and the created counts.

A provider failure partway through an import does not lose the record of what already landed: import_project raises MigrationImportError carrying the partial report (error set, counters and skipped reflecting everything created before the failure), and the CLI exits non-zero with a clean error message - still writing --report so the half-populated target is documented instead of leaving only a traceback.

What does not transfer 1:1 (and how it is reported)

Nothing is silently dropped: whatever cannot cross the neutral seam lands either in the bundle's omissions (export time) or the report's skipped list (import time), each entry a {kind, ref, reason} record.

Not transferredWhyWhere reported
Closed/merged PRs and git historyhistory replay is out of the neutral seam's scopeexport omission (pull-request)
Commit history of open PRsre-created as a single head-state snapshotimport skip (pull-request-history)
Issue/PR discussion commentsauthorship cannot be preserved across platformsexport omission (comments)
PR reviewsreview models differ per platformexport omission (reviews)
File deletions inside an open PRthe seam has no delete-file operationexport omission + import skip (pull-request-file)
Draft status of a PRnot expressible through the seamimport skip (pull-request)
Colors/descriptions of non-Bloom labelsthe seam has no label-listing readexport omission (label)
#N references to PRs or external itemsPR reference syntax is platform-specificimport skip (cross-reference), text left as-is
Item numbersplatforms assign their ownrenumbered; mapping in the report
CI history / provider-native automation configout of scope (per M23-6)not exported

Round-trip proof

tests/integration/test_migration_flow.py seeds a GitHub-flavored fake provider (milestones, status-labeled issues with cross-references, an open PR against an integration branch, a merged PR, comments, reviews), exports it with a PRD/plan/tickets core, and imports the serialized bundle into a second fake - asserting milestones, issues, labels, plan, and dependency edges arrive intact, cross-references are renumbered, and every non-transferable item appears in the report. tests/unit/test_migration_bundle.py pins the schema round-trip, determinism, and version-compat behavior; tests/unit/test_migration_cli.py covers the CLI end to end over the fakes.