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 theTicketPlanwhose title-keyeddependenciesedges carry the project's dependency graph. - Live platform state, read through the neutral
VCSProviderseam: 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:
- Labels - every label in use, with Bloom's coordination-label colors
(
status:*,type:*, …) restored fromLABEL_DEFS. - Milestones - by title, with description and open/closed state.
- Issues - title, body, state, labels (including the
status:*coordination label, so the coordinator resumes exactly where the project left off), and milestone link.#Ncross-references in issue bodies are renumbered to the target's issue numbers. - 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.
- 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 transferred | Why | Where reported |
|---|---|---|
| Closed/merged PRs and git history | history replay is out of the neutral seam's scope | export omission (pull-request) |
| Commit history of open PRs | re-created as a single head-state snapshot | import skip (pull-request-history) |
| Issue/PR discussion comments | authorship cannot be preserved across platforms | export omission (comments) |
| PR reviews | review models differ per platform | export omission (reviews) |
| File deletions inside an open PR | the seam has no delete-file operation | export omission + import skip (pull-request-file) |
| Draft status of a PR | not expressible through the seam | import skip (pull-request) |
| Colors/descriptions of non-Bloom labels | the seam has no label-listing read | export omission (label) |
#N references to PRs or external items | PR reference syntax is platform-specific | import skip (cross-reference), text left as-is |
| Item numbers | platforms assign their own | renumbered; mapping in the report |
| CI history / provider-native automation config | out 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.