Metadata-Version: 2.4
Name: plane-migrate
Version: 0.2.0
Summary: Migrate projects between Plane instances or workspaces
License: MIT License
        
        Copyright (c) 2025 Manish Gupta
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: migration,plane,project-management
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.11
Requires-Dist: plane-sdk<1.0.0,>=0.2.8
Requires-Dist: pyyaml>=6.0
Requires-Dist: rich>=13.0.0
Requires-Dist: typer>=0.9.0
Provides-Extra: dev
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: ruff>=0.4.0; extra == 'dev'
Description-Content-Type: text/markdown

# plane-migrate

A CLI tool to migrate projects between [Plane](https://plane.so) instances or between workspaces on the same instance — with full fidelity, resumable runs, and delta re-runs.

> **Works with**: Plane Cloud (`api.plane.so`), Plane Commercial (self-hosted), and Plane OSS (open-source) — in any combination.

---

## What gets migrated

| Entity | Details |
|:-------|:--------|
| **Work Item Types** | Custom types defined in the source project |
| **States** | All workflow states; matched by name to avoid duplicates |
| **Labels** | All labels; matched by name |
| **Members** | Matched by email across instances; unmatched members flagged in plan |
| **Estimates** | Estimate schema + all points (Points and Categories types) |
| **Work Items** | Title, description, state, priority, assignee, labels, type, dates, estimate point, parent — roots first, then children |
| **Relations** | `blocked_by`, `blocking`, `duplicate`, `relates_to` |
| **Links** | External URLs attached to work items |
| **Comments** | Full comment body with author attribution byline |
| **Modules** | With work item membership |
| **Cycles** | With work item membership |
| **Intake** | Intake work items |
| **Pages** | Requires `--src-session-cookie` (see [Pages](#pages-migration)) |

### Known limitations

| Gap | Reason |
|:----|:-------|
| Activity history | Plane API has no write endpoint for activity entries |
| File attachments | Plane API has no attachment upload endpoint |
| Page listing via API | Pages require a browser session cookie to list and fetch content |

---

## Cloud / Commercial vs OSS

Plane OSS exposes some entities at a different API path (`/api/` instead of `/api/v1/`). The migrator **auto-detects** which type each side is and uses the right path automatically.

| Feature | Cloud / Commercial | OSS |
|:--------|:-----------------:|:---:|
| Work items, states, labels, modules, cycles | `/api/v1/` SDK | `/api/v1/` SDK |
| Relations | `/api/v1/` SDK | `/api/` app API ¹ |
| Estimates | `/api/v1/` SDK | `/api/` app API ¹ |
| Pages (read) | `/api/` + src session cookie | `/api/` + src session cookie |
| Pages (write) | `/api/v1/` SDK | `/api/` + dst session cookie ¹ |

> ¹ Requires `--src-session-cookie` (for OSS source) or `--dst-session-cookie` (for OSS destination).

### Session cookies — when are they needed?

| Scenario | `--src-session-cookie` | `--dst-session-cookie` |
|:---------|:---------------------:|:---------------------:|
| Cloud → Cloud | pages only | — |
| Cloud → OSS | pages only | relations + estimates + pages |
| OSS → Cloud | relations + pages | — |
| OSS → OSS | relations + pages | relations + estimates + pages |

If a required cookie is missing, the affected entity type is skipped with a warning.

---

## Installation

**Requires Python 3.11+**

### Recommended — pipx (for CLI use)

[pipx](https://pipx.pypa.io) installs CLI tools in isolated environments and makes them available globally.

```bash
# macOS
brew install pipx && pipx ensurepath

# Debian / Ubuntu
sudo apt install pipx && pipx ensurepath

# Install plane-migrate
pipx install git+https://github.com/mguptahub/plane-migrate.git
```

### pip (inside a virtualenv)

```bash
python -m venv venv && source venv/bin/activate
pip install git+https://github.com/mguptahub/plane-migrate.git
```

### Local development

```bash
git clone https://github.com/mguptahub/plane-migrate.git
cd plane-migrate
python -m venv venv && source venv/bin/activate
pip install -e ".[dev]"
```

Verify:

```bash
plane-migrate --help
```

---

## Quick Start

### Step 1 — Dry run

Always start with a dry run. It fetches everything from source, previews what will be created, and writes a **plan YAML** — without touching the destination.

```bash
plane-migrate project \
  --src-url        https://api.plane.so \
  --src-token      <source-api-token> \
  --src-workspace  <source-workspace-slug> \
  --src-project    <source-project-id> \
  --dst-url        https://your-oss-instance.example.com \
  --dst-token      <destination-api-token> \
  --dst-workspace  <destination-workspace-slug> \
  --dst-project    <destination-project-id> \
  --dry-run
```

This writes a `migration-plan-<timestamp>.yaml` in the current directory.

### Step 2 — Review the plan

```yaml
members:
  mapped:
    - alice@old.com: alice@new.com    # auto-matched by email
  unmatched:
    bob@old.com: ""                   # ← fill in destination email
  _hint: Fill in destination emails for unmatched members, then pass this file via --members-map.

states:
  create: [Todo, In Progress, Done]
  skip:   [Backlog]                   # already exist on destination

work_items:
  total:    142
  roots:    98
  children: 44
```

Fill in any blank destination emails under `members.unmatched`.

### Step 3 — Real run

```bash
plane-migrate project \
  --src-url        https://api.plane.so \
  --src-token      <source-api-token> \
  --src-workspace  <source-workspace-slug> \
  --src-project    <source-project-id> \
  --dst-url        https://your-oss-instance.example.com \
  --dst-token      <destination-api-token> \
  --dst-workspace  <destination-workspace-slug> \
  --dst-project    <destination-project-id> \
  --members-map    migration-plan-<timestamp>.yaml \
  --src-session-cookie "csrftoken=abc; sessionid=xyz" \
  --dst-session-cookie "csrftoken=def; sessionid=uvw"
```

---

## Environment Variables

All flags can be set via environment variables — useful for CI or repeated runs:

| Variable | Flag equivalent |
|:---------|:----------------|
| `PLANE_SRC_URL` | `--src-url` |
| `PLANE_SRC_TOKEN` | `--src-token` |
| `PLANE_SRC_WORKSPACE` | `--src-workspace` |
| `PLANE_DST_URL` | `--dst-url` |
| `PLANE_DST_TOKEN` | `--dst-token` |
| `PLANE_DST_WORKSPACE` | `--dst-workspace` |
| `PLANE_SRC_SESSION_COOKIE` | `--src-session-cookie` |
| `PLANE_DST_SESSION_COOKIE` | `--dst-session-cookie` |
| `PLANE_MIGRATE_LOG` | `--log-file` |

> `PLANE_SESSION_COOKIE` is still accepted as a fallback for `--src-session-cookie` for backward compatibility.

```bash
export PLANE_SRC_URL=https://api.plane.so
export PLANE_SRC_TOKEN=plane_api_...
export PLANE_SRC_WORKSPACE=my-workspace
export PLANE_DST_URL=https://oss.example.com
export PLANE_DST_TOKEN=plane_api_...
export PLANE_DST_WORKSPACE=my-workspace
export PLANE_SRC_SESSION_COOKIE="csrftoken=abc; sessionid=xyz"
export PLANE_DST_SESSION_COOKIE="csrftoken=def; sessionid=uvw"

plane-migrate project \
  --src-project <src-project-id> \
  --dst-project <dst-project-id>
```

---

## All CLI Flags

```
plane-migrate project [OPTIONS]

Source:
  --src-url TEXT               Source Plane instance URL
  --src-token TEXT             Source API token
  --src-workspace TEXT         Source workspace slug
  --src-project TEXT           Source project UUID or key
  --src-session-cookie TEXT    Browser session cookie for source instance
                               (OSS relations + pages)  [env: PLANE_SRC_SESSION_COOKIE]

Destination:
  --dst-url TEXT               Destination Plane instance URL
  --dst-token TEXT             Destination API token
  --dst-workspace TEXT         Destination workspace slug
  --dst-project TEXT           Destination project UUID or key
  --dst-session-cookie TEXT    Browser session cookie for destination instance
                               (OSS relations + estimates + pages)  [env: PLANE_DST_SESSION_COOKIE]

Options:
  --dry-run / -n               Preview what would be migrated — writes a plan YAML
  --only TEXT                  Migrate only specific entities (repeatable)
  --members-map TEXT           YAML file mapping src emails → dst emails
  --plan-file TEXT             Where to write the dry-run plan (default: migration-plan-<ts>.yaml)
  --state-file TEXT            State file path for resumable runs (default: <src>-<dst>.json)
  --log-file TEXT              Log file path  [env: PLANE_MIGRATE_LOG]
```

---

## Selective Migration — `--only`

Migrate only specific entity types. Can be repeated.

```bash
# Re-run only links and comments (fill gaps)
plane-migrate project ... --only links --only comments

# Migrate only work items and relations
plane-migrate project ... --only work-items --only relations

# Migrate pages from Cloud source to OSS destination
plane-migrate project ... --only pages \
  --src-session-cookie "csrftoken=abc; sessionid=xyz" \
  --dst-session-cookie "csrftoken=def; sessionid=uvw"

# Migrate relations to an OSS destination
plane-migrate project ... --only relations \
  --dst-session-cookie "csrftoken=def; sessionid=uvw"
```

**Valid `--only` values:**

| Value | What it migrates |
|:------|:-----------------|
| `types` | Work item types |
| `states` | Workflow states |
| `labels` | Labels |
| `members` | Member matching (no destination writes) |
| `estimates` | Estimate schema + points |
| `work-items` | All work items (roots + children) |
| `relations` | Work item relations |
| `links` | External URL links on work items |
| `comments` | Work item comments |
| `modules` | Modules + work item membership |
| `cycles` | Cycles + work item membership |
| `intake` | Intake work items |
| `pages` | Pages (requires `--src-session-cookie`) |

---

## Resumable Runs & Gap Filling

Every successful write is tracked in a **state file** (`<src-project-id>-<dst-project-id>.json`). If a run is interrupted — network error, rate limit, crash — just rerun the same command. The migrator will:

- **Skip** entities already created (tracked by source ID → destination ID mapping)
- **Fill gaps** for links and comments — only creates the ones missing per work item
- **Save state incrementally** — progress is never lost mid-run

```bash
# Interrupted run — just rerun, it picks up where it left off
plane-migrate project ... --state-file my-migration.json
```

> Keep the state file between runs. Deleting it causes the migrator to treat everything as new — resulting in duplicates on the destination.

---

## Members Mapping

Members are matched **by email** between source and destination. When migrating within the same instance, members are automatically matched.

When migrating across instances, emails may differ. Unmatched members appear in the dry-run plan:

```yaml
members:
  unmatched:
    old-email@company.com: ""    # ← fill in new email
```

Pass the edited plan file (or any YAML with `src_email: dst_email` pairs) via `--members-map`:

```bash
plane-migrate project ... --members-map migration-plan.yaml
```

Work items assigned to unmatched members will still be migrated — but without an assignee.

---

## Pages Migration

Pages require a **browser session cookie** because the public API does not expose a page listing endpoint.

- `--src-session-cookie` — used to **list and fetch** page content from the source
- `--dst-session-cookie` — used to **create** pages on an OSS destination (not needed for Cloud/Commercial destinations)

### Getting the session cookie

1. Open your Plane instance in a browser and log in
2. Open DevTools → Application → Cookies
3. Copy the values of `csrftoken` and `sessionid`
4. Pass them as a single string:

```bash
# Cloud source → Cloud destination (only src cookie needed)
plane-migrate project ... --only pages \
  --src-session-cookie "csrftoken=abc123; sessionid=xyz789"

# Cloud source → OSS destination (both cookies needed)
plane-migrate project ... --only pages \
  --src-session-cookie "csrftoken=abc123; sessionid=xyz789" \
  --dst-session-cookie "csrftoken=def456; sessionid=uvw012"
```

If `--src-session-cookie` is not provided, pages are skipped with a note in the summary.

---

## Logging

Every run writes a structured JSON log to `plane-migrate-<timestamp>.log` (or the path from `--log-file` / `PLANE_MIGRATE_LOG`).

Each line is a JSON event:

```json
{"ts": "2025-04-01T10:23:45Z", "kind": "created", "entity": "work_item", "name": "Fix login bug", "src_id": "abc", "dst_id": "xyz"}
{"ts": "2025-04-01T10:23:46Z", "kind": "skipped", "entity": "label",     "name": "Bug", "detail": "already exists"}
{"ts": "2025-04-01T10:23:47Z", "kind": "failed",  "entity": "comment",   "name": "on abc", "detail": "429 Too Many Requests"}
```

Tail in real time:

```bash
tail -f plane-migrate-*.log | python3 -c "
import sys, json
for line in sys.stdin:
    e = json.loads(line)
    print(f\"{e['ts'][11:19]} [{e['kind']:8}] {e['entity']:12} {e['name']}\")
"
```

---

## Rate Limiting

The migrator writes at ~3 requests/second (`300ms` delay between writes). On HTTP `429 Too Many Requests`, it backs off automatically and shows a live countdown in the terminal:

```
⏳ Rate limited — retrying in 28s (attempt 1/3)...
```

- Wait 30s → retry
- Wait 45s → retry
- Wait 60s → retry
- Fail and log the error

Failures are logged and counted but do not abort the run. Use `--only` to re-run specific phases after hitting rate limits.

---

## Cross-Instance Examples

### Cloud → OSS

```bash
plane-migrate project \
  --src-url        https://api.plane.so \
  --src-token      plane_api_<cloud-token> \
  --src-workspace  my-cloud-workspace \
  --src-project    <project-id> \
  --dst-url        https://oss.mycompany.com \
  --dst-token      plane_api_<oss-token> \
  --dst-workspace  my-oss-workspace \
  --dst-project    <project-id> \
  --members-map    members.yaml \
  --src-session-cookie "csrftoken=abc; sessionid=xyz" \
  --dst-session-cookie "csrftoken=def; sessionid=uvw"
```

### Cloud → Cloud (or Commercial → Commercial)

```bash
plane-migrate project \
  --src-url        https://api.plane.so \
  --src-token      plane_api_<src-token> \
  --src-workspace  source-workspace \
  --src-project    <project-id> \
  --dst-url        https://api.plane.so \
  --dst-token      plane_api_<dst-token> \
  --dst-workspace  destination-workspace \
  --dst-project    <project-id> \
  --members-map    members.yaml \
  --src-session-cookie "csrftoken=abc; sessionid=xyz"
```

### Same-Instance Workspace Migration

```bash
plane-migrate project \
  --src-url        https://api.plane.so \
  --src-token      plane_api_<token> \
  --src-workspace  old-workspace \
  --src-project    <src-project-id> \
  --dst-url        https://api.plane.so \
  --dst-token      plane_api_<token> \
  --dst-workspace  new-workspace \
  --dst-project    <dst-project-id>
```

Members are auto-matched by email — no `--members-map` needed.

---

## Comparison with Plane's Native Export

| Capability | CSV Export/Import | JSON Export | plane-migrate |
|:-----------|:-----------------:|:-----------:|:-------------:|
| Work items | ✓ (flat) | ✓ | ✓ |
| States / Labels | names only | names only | ✓ full remap |
| Comments | ✗ | ✓ | ✓ with attribution |
| Relations | ✗ | ✓ | ✓ |
| Links | ✗ | ✓ | ✓ |
| Modules / Cycles | ✗ | names only | ✓ with membership |
| Estimates | ✗ | ✗ | ✓ |
| Pages | ✗ | ✗ | ✓ (session cookie) |
| Intake | ✗ | ✗ | ✓ |
| Member remapping | ✗ | ✗ | ✓ |
| Resumable | ✗ | ✗ | ✓ |
| OSS compatible | ✗ | ✗ | ✓ |

---

## Contributing

Pull requests are welcome. When contributing:

- Keep the migration sequence order (types → states → labels → members → estimates → work-items → relations → links → comments → modules → cycles → intake → pages)
- All new entity types should follow the same pattern: fetch from source → remap IDs → create on destination → track in state file
- State must be saved incrementally (per entity, not at the end of the run)
- For OSS compatibility: if an entity's `/api/v1/` endpoint returns 404, check whether `/api/` has the equivalent and add a fallback (see `oss_relations.py` and `_oss_app_post()` as reference)

---

## License

MIT
