diff --git a/packages/import/.env.example b/packages/import/.env.example
new file mode 100644
index 00000000..2846f62b
--- /dev/null
+++ b/packages/import/.env.example
@@ -0,0 +1,12 @@
+# https://trello.com/app-key
+TRELLO_API_KEY=
+TRELLO_API_TOKEN=
+TRELLO_BOARD_ID=1yyR7LdA
+LINEAR_TEAM_ID=FON
+LINEAR_PROJECT_URL=https://linear.app/fontself/project/proj-d1563b5a7fed
+ATTACHMENT_CACHE_DIR=
+TRELLO_JSON_PATH=
+MAP_LISTS_TO_STATUSES=true
+DISCARD_ARCHIVED_CARDS=false
+DISCARD_ARCHIVED_LISTS=false
+LINEAR_API_KEY=lin_api_
\ No newline at end of file
diff --git a/packages/import/.gitignore b/packages/import/.gitignore
new file mode 100644
index 00000000..688078b9
--- /dev/null
+++ b/packages/import/.gitignore
@@ -0,0 +1,15 @@
+# Runtime import state โ do not commit
+.env
+import-progress.json
+.attachment-cache.json
+
+# Debug / scratch scripts
+debug-*.mjs
+test-*.mjs
+test-*.ts
+debug-*.ts
+exampleReplace.js
+
+# Scratch test files in importers
+src/importers/trelloJson/exampleReplace.js
+src/importers/trelloJson/test-trello-api.ts
diff --git a/packages/import/README.md b/packages/import/README.md
index 10c2bd3c..bc2c9380 100644
--- a/packages/import/README.md
+++ b/packages/import/README.md
@@ -103,9 +103,59 @@ The following fields are supported:
- `Description` - Trello markdown formatted description
- `URL` - URL of Trello card
- `Labels` - Added as a label
-- `Attachments` - Added as links in the description
+- `Attachments` - Downloaded from Trello and re-uploaded to Linear; URLs replaced in description
- (Optional) `Comments` - Added in the description
+#### Non-interactive import (recommended)
+
+For large boards, use `run-trello-import.mjs` to bypass all interactive prompts.
+
+**Prerequisites:** build the SDK first:
+
+```bash
+cd packages/sdk && pnpm run build:sdk && cd ../..
+```
+
+**Setup:** create `packages/import/.env`:
+
+```
+LINEAR_API_KEY=lin_api_...
+TRELLO_API_KEY=...
+TRELLO_API_TOKEN=...
+LINEAR_TEAM_ID=FON # team key or UUID
+LINEAR_PROJECT_URL=https://linear.app/yourorg/project/project-name-slugid # optional
+TRELLO_JSON_PATH=/path/to/board-export.json # used by patch-trello-urls.mjs
+ATTACHMENT_CACHE_DIR=/path/to/attachments-cache # used by patch-trello-urls.mjs
+MAP_LISTS_TO_STATUSES=true
+DISCARD_ARCHIVED_CARDS=false
+DISCARD_ARCHIVED_LISTS=false
+```
+
+Get Trello credentials at https://trello.com/app-key.
+
+**Run:**
+
+```bash
+cd packages/import
+
+# Preview (no data written)
+pnpm exec tsx run-trello-import.mjs /path/to/board-export.json --dry-run
+
+# Full import
+pnpm exec tsx run-trello-import.mjs /path/to/board-export.json
+```
+
+**Resume after interruption:** the script writes `import-progress.json` after each issue is created. Re-running will skip already-imported issues. Attachment files are cached in `attachments-cache/` next to the JSON export so they are not re-downloaded.
+
+**Patch existing issues:** if issues were created before attachment upload was working, use:
+
+```bash
+pnpm exec tsx patch-trello-urls.mjs --dry-run # preview
+pnpm exec tsx patch-trello-urls.mjs # apply
+```
+
+This script scans all issues in the target project for remaining Trello attachment URLs and replaces them with re-uploaded Linear asset URLs.
+
### Linear CSV
Linear CSV exports (Settings โ Import / Export โ Export CSV) can be imported into Linear again. You can use this to import issues from one workspace to another. Archived issues won't be imported.
@@ -141,9 +191,11 @@ The following fields are supported:
- `Time Estimate` - Issue estimate
+
## License
Licensed under the [MIT License](./LICENSE).
+
diff --git a/packages/import/TRELLO_API_SETUP.md b/packages/import/TRELLO_API_SETUP.md
new file mode 100644
index 00000000..74d12b99
--- /dev/null
+++ b/packages/import/TRELLO_API_SETUP.md
@@ -0,0 +1,124 @@
+# Trello API Setup & Testing Guide
+
+## ๐ Step 1: Get Trello API Credentials
+
+### Generate API Key & Token
+
+1. Go to: **https://trello.com/app-key**
+2. Copy your **API Key** (long alphanumeric string)
+3. Scroll down and click **"Token"** link to generate a token
+4. Grant it **read** scope (minimum required)
+5. Copy the **Token** that appears
+
+### Get Board ID
+
+1. Open your Trello board in browser
+2. The URL looks like: `https://trello.com/b/BOARD_ID/board-name`
+3. Copy the `BOARD_ID` part
+
+## ๐ง Step 2: Create Environment File
+
+Create `.env` file in `packages/import/`:
+
+```bash
+cd packages/import/
+cat > .env << EOF
+TRELLO_API_KEY=your_api_key_here
+TRELLO_API_TOKEN=your_token_here
+TRELLO_BOARD_ID=your_board_id_here
+LINEAR_API_KEY=your_linear_key_here
+EOF
+```
+
+**Important:** Never commit `.env` to git (should be in `.gitignore`)
+
+## ๐งช Step 3: Run Dry-Run Test
+
+Test your credentials and preview what will be downloaded:
+
+```bash
+cd packages/import/
+npx ts-node src/importers/trelloJson/test-trello-api.ts
+```
+
+**Expected output:**
+
+```
+๐ Trello API Test - Attachment Download
+
+Mode: DRY-RUN (no files downloaded)
+
+1๏ธโฃ Testing API access...
+ โ
Connected to board: "My Trello Board"
+
+2๏ธโฃ Fetching board structure...
+ Found 5 lists (columns)
+ Found 42 total cards, 15 with attachments
+ Total attachments: 23
+
+3๏ธโฃ Processing attachments...
+
+๐ [To Do] "Design homepage"
+ ๐ wireframe.png
+ [dry-run] Would download from: https://trello.com/1/cards/...
+ [dry-run] Save to: attachments/To Do/Design homepage/wireframe.png
+...
+```
+
+## ๐ฅ Step 4: Execute Real Download
+
+Once dry-run looks good, actually download files:
+
+```bash
+cd packages/import/
+npx ts-node src/importers/trelloJson/test-trello-api.ts --download
+```
+
+This creates the `attachments/` directory structure:
+
+```
+attachments/
+โโโ To Do/
+โ โโโ Design homepage/
+โ โ โโโ wireframe.png
+โ โ โโโ requirements.pdf
+โโโ In Progress/
+โ โโโ Build feature/
+โ โโโ screenshot.jpg
+```
+
+## ๐ Troubleshooting
+
+### "401 Unauthorized"
+
+- API Key or Token is incorrect
+- Verify credentials at https://trello.com/app-key
+- Token may have expired (regenerate it)
+
+### "404 Not Found"
+
+- Board ID is incorrect
+- Copy it directly from your board URL
+
+### Network timeouts
+
+- Large file? Check your internet connection
+- Retry the command
+
+### "No attachments found"
+
+- Are there actually attachments on your board?
+- Check if they're in archived cards (won't be fetched by default)
+
+## โ
Next Steps
+
+After successful dry-run and download:
+
+1. Run the full Trello import with attachment support (when ready)
+2. Check that `attachments/` directory was created correctly
+3. Run `exampleReplace.js` to upload files to Linear
+
+## ๐ References
+
+- **Trello API Guide:** https://developer.trello.com/reference
+- **Card Attachments:** https://developer.trello.com/reference/#card-object
diff --git a/packages/import/patch-trello-urls.mjs b/packages/import/patch-trello-urls.mjs
new file mode 100644
index 00000000..7a9e5598
--- /dev/null
+++ b/packages/import/patch-trello-urls.mjs
@@ -0,0 +1,200 @@
+/**
+ * Patch script: find all issues in a Linear project that still have Trello
+ * attachment URLs and replace them with uploaded Linear asset URLs.
+ *
+ * Usage:
+ * pnpm exec tsx patch-trello-urls.mjs [--dry-run]
+ */
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// Load .env
+const env = readFileSync(path.join(__dirname, ".env"), "utf-8");
+for (const line of env.split("\n")) {
+ const t = line.trim();
+ if (!t || t.startsWith("#")) {
+ continue;
+ }
+ const [k, ...r] = t.split("=");
+ if (k && r.length) {
+ process.env[k.trim()] = r
+ .join("=")
+ .trim()
+ .replace(/\s+#.*$/, "")
+ .trim();
+ }
+}
+
+const DRY_RUN = process.argv.includes("--dry-run");
+
+// These must be set in .env:
+// LINEAR_PROJECT_URL - full Linear project URL (used to resolve PROJECT_ID)
+// ATTACHMENT_CACHE_DIR - path to the attachments-cache directory
+// TRELLO_JSON_PATH - path to the Trello board JSON export
+const ATTACHMENT_CACHE_DIR = process.env.ATTACHMENT_CACHE_DIR;
+const TRELLO_JSON = process.env.TRELLO_JSON_PATH;
+
+if (!ATTACHMENT_CACHE_DIR || !TRELLO_JSON) {
+ console.error("Missing required env vars: ATTACHMENT_CACHE_DIR, TRELLO_JSON_PATH");
+ process.exit(1);
+}
+
+const { LinearClient } = await import("@linear/sdk");
+const client = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });
+
+// Resolve project ID from LINEAR_PROJECT_URL
+if (!process.env.LINEAR_PROJECT_URL) {
+ console.error("Missing required env var: LINEAR_PROJECT_URL");
+ process.exit(1);
+}
+const projectUrlSlug = process.env.LINEAR_PROJECT_URL.trim().split("/").pop();
+const allTeams = await client.paginate(client.teams, {});
+let PROJECT_ID;
+for (const team of allTeams) {
+ const projects = await team.paginate(team.projects, {});
+ const match = projects.find(p => p.url?.includes(projectUrlSlug) || p.slugId === projectUrlSlug);
+ if (match) {
+ PROJECT_ID = match.id;
+ break;
+ }
+}
+if (!PROJECT_ID) {
+ console.error(`Could not resolve project from LINEAR_PROJECT_URL: ${process.env.LINEAR_PROJECT_URL}`);
+ process.exit(1);
+}
+
+// Build card lookup from Trello JSON
+const trelloData = JSON.parse(readFileSync(TRELLO_JSON, "utf-8"));
+const cardById = new Map(trelloData.cards.map(c => [c.id, c]));
+
+// Build cache map
+const cacheMap = new Map();
+for (const file of (await import("node:fs")).readdirSync(ATTACHMENT_CACHE_DIR)) {
+ cacheMap.set(file, path.join(ATTACHMENT_CACHE_DIR, file));
+}
+console.log(`Loaded ${cacheMap.size} cached attachments\n`);
+
+const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+function getMimeType(name) {
+ const ext = name.split(".").pop()?.toLowerCase();
+ const map = {
+ png: "image/png",
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ gif: "image/gif",
+ webp: "image/webp",
+ pdf: "application/pdf",
+ otf: "font/otf",
+ ttf: "font/ttf",
+ woff: "font/woff",
+ woff2: "font/woff2",
+ };
+ return map[ext] ?? "application/octet-stream";
+}
+
+// Fetch all issues in project that contain trello.com/1/cards URLs
+console.log("Fetching issues from project...");
+const project = await client.project(PROJECT_ID);
+let cursor;
+let total = 0;
+let fixed = 0;
+let skipped = 0;
+
+while (true) {
+ const issues = await project.issues({
+ first: 50,
+ after: cursor,
+ filter: { description: { contains: "trello.com/1/cards" } },
+ });
+
+ for (const issue of issues.nodes) {
+ total++;
+ const fullIssue = await client.issue(issue.id);
+ const desc = (await fullIssue.description) ?? "";
+
+ // Extract the Trello card ID from URLs in the description
+ const cardIdMatch = desc.match(/trello\.com\/1\/cards\/([a-f0-9]+)\//);
+ if (!cardIdMatch) {
+ skipped++;
+ continue;
+ }
+ const cardId = cardIdMatch[1];
+ const card = cardById.get(cardId);
+ if (!card) {
+ skipped++;
+ continue;
+ }
+
+ let updatedDesc = desc;
+ let changed = false;
+
+ for (const att of card.attachments ?? []) {
+ const cacheFilename = `${cardId}_${att.id}_${att.name}`;
+ const cachedPath = cacheMap.get(cacheFilename);
+ if (!cachedPath) {
+ continue;
+ }
+
+ const mimeType = getMimeType(att.name);
+ const data = readFileSync(cachedPath);
+
+ if (DRY_RUN) {
+ const pattern = new RegExp(`!?\\[${escapeRegex(att.name)}\\]\\(${escapeRegex(att.url)}>?\\)`, "g");
+ const matches = [...updatedDesc.matchAll(pattern)];
+ if (matches.length) {
+ console.log(` [DRY] ${issue.identifier} โ would replace ${matches.length}x "${att.name}"`);
+ changed = true;
+ }
+ continue;
+ }
+
+ // Upload to Linear
+ const upload = await client.fileUpload(mimeType, att.name, data.length);
+ if (!upload.success || !upload.uploadFile) {
+ continue;
+ }
+
+ const { uploadUrl, assetUrl, headers: uploadHeaders } = upload.uploadFile;
+ const headers = { "Content-Type": mimeType, "Content-Length": String(data.length) };
+ for (const h of uploadHeaders) {
+ headers[h.key] = h.value;
+ }
+
+ const putRes = await fetch(uploadUrl, { method: "PUT", headers, body: data, duplex: "half" });
+ if (!putRes.ok) {
+ continue;
+ }
+
+ const isImage = mimeType.startsWith("image/");
+ const replacement = isImage ? `` : `[${att.name}](${assetUrl})`;
+ const pattern = new RegExp(`!?\\[${escapeRegex(att.name)}\\]\\(${escapeRegex(att.url)}>?\\)`, "g");
+ const newDesc = updatedDesc.replace(pattern, replacement);
+ if (newDesc !== updatedDesc) {
+ updatedDesc = newDesc;
+ changed = true;
+ }
+ }
+
+ if (changed && !DRY_RUN) {
+ await client.updateIssue(issue.id, { description: updatedDesc });
+ console.log(` โ Fixed ${issue.identifier} โ ${issue.title}`);
+ fixed++;
+ } else if (!changed) {
+ skipped++;
+ }
+ }
+
+ if (!issues.pageInfo.hasNextPage) {
+ break;
+ }
+ cursor = issues.pageInfo.endCursor;
+}
+
+console.log(`\nDone. Scanned ${total} issues with Trello URLs. Fixed: ${fixed}. Skipped: ${skipped}.`);
+if (DRY_RUN) {
+ console.log("(dry run โ no changes made)");
+}
diff --git a/packages/import/run-trello-import.mjs b/packages/import/run-trello-import.mjs
new file mode 100644
index 00000000..86f8bd73
--- /dev/null
+++ b/packages/import/run-trello-import.mjs
@@ -0,0 +1,174 @@
+#!/usr/bin/env node
+/**
+ * Non-interactive Trello import script.
+ *
+ * USAGE:
+ * tsx run-trello-import.mjs [--dry-run]
+ *
+ * REQUIRED ENV (set in .env or export before running):
+ * LINEAR_API_KEY - your Linear API key
+ * TRELLO_API_KEY - your Trello API key
+ * TRELLO_API_TOKEN - your Trello API token
+ *
+ * OPTIONAL ENV:
+ * LINEAR_TEAM_ID - import into this existing team (skips team prompt)
+ * LINEAR_PROJECT_URL - assign all issues to this project (full URL)
+ * ATTACHMENT_CACHE_DIR - dir to cache downloaded attachments (default: attachments-cache/ next to JSON)
+ * MAP_LISTS_TO_STATUSES - "true" (default) or "false"
+ * DISCARD_ARCHIVED_CARDS - "true" (default) or "false"
+ * DISCARD_ARCHIVED_LISTS - "true" (default) or "false"
+ */
+
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// Load .env
+try {
+ const env = readFileSync(resolve(__dirname, ".env"), "utf-8");
+ for (const line of env.split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) {
+ continue;
+ }
+ const [key, ...rest] = trimmed.split("=");
+ if (key && rest.length) {
+ // Strip inline comments (e.g. `false # default: true` โ `false`)
+ const raw = rest.join("=").trim();
+ process.env[key.trim()] = raw.replace(/\s+#.*$/, "").trim();
+ }
+ }
+} catch {}
+
+const trelloJsonPath = process.argv[2];
+if (!trelloJsonPath) {
+ console.error("Usage: node run-trello-import.mjs [--dry-run]");
+ process.exit(1);
+}
+
+const DRY_RUN = process.argv.includes("--dry-run") || process.env.DRY_RUN === "true";
+
+const LINEAR_API_KEY = process.env.LINEAR_API_KEY;
+const TRELLO_API_KEY = process.env.TRELLO_API_KEY;
+const TRELLO_API_TOKEN = process.env.TRELLO_API_TOKEN;
+
+if (!LINEAR_API_KEY) {
+ console.error("Missing LINEAR_API_KEY in environment / .env");
+ process.exit(1);
+}
+
+if (!TRELLO_API_KEY || !TRELLO_API_TOKEN) {
+ console.error("Missing TRELLO_API_KEY or TRELLO_API_TOKEN in environment / .env");
+ process.exit(1);
+}
+
+// Dynamically import compiled/ts-node modules
+const { LinearClient } = await import("@linear/sdk");
+const { TrelloJsonImporter } = await import("./src/importers/trelloJson/TrelloJsonImporter.ts");
+const { importIssues } = await import("./src/importIssues.ts");
+
+const mapListsToStatuses = process.env.MAP_LISTS_TO_STATUSES !== "false";
+const discardArchivedCards = process.env.DISCARD_ARCHIVED_CARDS !== "false";
+const discardArchivedLists = process.env.DISCARD_ARCHIVED_LISTS !== "false";
+
+const importer = new TrelloJsonImporter(
+ resolve(trelloJsonPath),
+ mapListsToStatuses,
+ discardArchivedCards,
+ discardArchivedLists,
+ DRY_RUN ? undefined : TRELLO_API_KEY, // skip attachment downloads in dry-run
+ DRY_RUN ? undefined : TRELLO_API_TOKEN
+);
+
+// If LINEAR_TEAM_ID is set, patch importIssues to skip team selection prompt.
+// Accepts either a team UUID or a team key (e.g. "FON").
+if (process.env.LINEAR_TEAM_ID) {
+ const { LinearClient } = await import("@linear/sdk");
+ const client = new LinearClient({ apiKey: LINEAR_API_KEY });
+ const allTeams = await client.paginate(client.teams, {});
+ const teamIdOrKey = process.env.LINEAR_TEAM_ID.trim();
+ const team = allTeams.find(t => t.id === teamIdOrKey || t.key === teamIdOrKey);
+ if (!team) {
+ console.error(`Team not found: "${teamIdOrKey}". Available teams:`);
+ for (const t of allTeams) {
+ console.error(` [${t.key}] ${t.displayName} โ id: ${t.id}`);
+ }
+ process.exit(1);
+ }
+ console.log(`Using team: [${team.key}] ${team.displayName} (${team.id})\n`);
+
+ const resolvedTeamId = team.id;
+ const inquirerMod = await import("inquirer");
+ const inquirer = inquirerMod.default;
+ // Resolve project UUID from URL if provided
+ let resolvedProjectId;
+ if (process.env.LINEAR_PROJECT_URL) {
+ const projectUrlSlug = process.env.LINEAR_PROJECT_URL.trim().split("/").pop();
+ const teamObj = await client.team(resolvedTeamId);
+ const teamProjects = await teamObj.paginate(teamObj.projects, {});
+ const matched = teamProjects.find(p => p.url?.includes(projectUrlSlug) || p.slugId === projectUrlSlug);
+ if (matched) {
+ resolvedProjectId = matched.id;
+ console.log(`Using project: ${matched.name} (${matched.id})\n`);
+ } else {
+ console.warn(`Warning: project not found for URL: ${process.env.LINEAR_PROJECT_URL}`);
+ console.warn(`Available projects: ${teamProjects.map(p => p.name).join(", ")}`);
+ }
+ }
+
+ inquirer.prompt = async questions => {
+ const answers = {};
+ for (const q of [].concat(questions)) {
+ if (q.name === "newTeam") {
+ answers.newTeam = false;
+ } else if (q.name === "targetTeamId") {
+ answers.targetTeamId = resolvedTeamId;
+ } else if (q.name === "includeComments") {
+ answers.includeComments = false;
+ } else if (q.name === "selfAssign") {
+ answers.selfAssign = false;
+ } else if (q.name === "targetAssignee") {
+ answers.targetAssignee = "";
+ } else if (q.name === "includeProject") {
+ answers.includeProject = !!resolvedProjectId;
+ } else if (q.name === "targetProjectId") {
+ answers.targetProjectId = resolvedProjectId;
+ } else if (typeof q.default !== "undefined") {
+ answers[q.name] = q.default;
+ } else {
+ answers[q.name] = "";
+ }
+ }
+ return answers;
+ };
+}
+
+console.log(`Importing: ${trelloJsonPath}`);
+console.log(`Map lists to statuses: ${mapListsToStatuses}`);
+console.log(`Discard archived cards: ${discardArchivedCards}`);
+console.log(`Discard archived lists: ${discardArchivedLists}`);
+console.log();
+
+if (DRY_RUN) {
+ console.log("=== DRY RUN โ no data will be sent to Linear ===\n");
+ const data = await importer.import();
+ const byStatus = {};
+ let totalAttachments = 0;
+ for (const issue of data.issues) {
+ const s = issue.status ?? "(no status)";
+ byStatus[s] = (byStatus[s] ?? 0) + 1;
+ totalAttachments += issue.attachments?.length ?? 0;
+ }
+ console.log(`Issues: ${data.issues.length}`);
+ console.log(`Labels: ${Object.keys(data.labels).length}`);
+ console.log(`Attachments: ${totalAttachments}`);
+ console.log(`Archived: ${data.issues.filter(i => i.archived).length}`);
+ console.log("\nBreakdown by list/status:");
+ for (const [status, count] of Object.entries(byStatus)) {
+ console.log(` ${count.toString().padStart(4)} ${status}`);
+ }
+} else {
+ await importIssues(LINEAR_API_KEY, importer);
+}
diff --git a/packages/import/src/importIssues.ts b/packages/import/src/importIssues.ts
index 70acc6c5..8f8164ab 100644
--- a/packages/import/src/importIssues.ts
+++ b/packages/import/src/importIssues.ts
@@ -1,10 +1,11 @@
/* eslint-disable no-console */
import { LinearClient } from "@linear/sdk";
import chalk from "chalk";
-import { Presets, SingleBar } from "cli-progress";
import { format } from "date-fns";
import inquirer from "inquirer";
import uniq from "lodash/uniq.js";
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import { resolve as resolvePath } from "node:path";
import ora from "ora";
import { handleLabels } from "./helpers/labelManager.ts";
import type { Comment, Importer, ImportResult } from "./types.ts";
@@ -40,7 +41,9 @@ const defaultStateColors: Record = {
*/
export const importIssues = async (apiKey: string, importer: Importer, apiUrl?: string): Promise => {
const client = new LinearClient({ apiKey, apiUrl });
+ console.info("Parsing import data...");
const importData = await importer.import();
+ console.info(` โ ${importData.issues.length} issues, ${Object.keys(importData.labels).length} labels\n`);
const viewerQuery = await client.viewer;
@@ -192,11 +195,13 @@ export const importIssues = async (apiKey: string, importer: Importer, apiUrl?:
const allWorkspaceLabels = await client.paginate(organization.labels, {});
spinner.stop();
+ console.info(`Fetched ${allTeamLabels.length + allWorkspaceLabels.length} existing labels`);
spinner = ora("Fetching workflow states").start();
const workflowStates = await teamInfo?.states();
spinner.stop();
+ console.info(`Fetched ${workflowStates?.nodes?.length ?? 0} workflow states`);
spinner = ora("Updating labels").start();
const projectId = importAnswers.targetProjectId;
@@ -224,15 +229,30 @@ export const importIssues = async (apiKey: string, importer: Importer, apiUrl?:
}
spinner.stop();
- const issuesProgressBar = new SingleBar({}, Presets.shades_classic);
- issuesProgressBar.start(importData.issues.length, 0);
+ console.info(`Label mapping ready (${Object.keys(labelMapping).length} labels)\n`);
+
+ // Resume support: track which issues (by Trello URL) have already been created
+ const progressFile = resolvePath(process.cwd(), "import-progress.json");
+ const progress: Record = existsSync(progressFile)
+ ? JSON.parse(readFileSync(progressFile, "utf-8"))
+ : {};
+ const resuming = Object.keys(progress).length > 0;
+ const total = importData.issues.length;
+ if (resuming) {
+ console.info(`Resuming: ${Object.keys(progress).length} issues already imported, skipping...\n`);
+ }
+
+ console.info(`Starting import of ${total} issues...\n`);
let issueCursor = 0;
// Create issues
for (const issue of importData.issues) {
- const issueDescription = issue.description
- ? await replaceImagesInMarkdown(client, issue.description, importData.resourceURLSuffix)
- : undefined;
+ // Skip replaceImagesInMarkdown when the issue has attachments โ the attachment upload
+ // step handles URL replacement itself, and Trello image URLs require OAuth auth anyway.
+ const issueDescription =
+ issue.description && !issue.attachments?.length
+ ? await replaceImagesInMarkdown(client, issue.description, importData.resourceURLSuffix)
+ : issue.description;
const description =
importAnswers.includeComments && issue.comments
@@ -282,9 +302,16 @@ export const importIssues = async (apiKey: string, importer: Importer, apiUrl?:
const formattedDueDate = issue.dueDate ? format(issue.dueDate, "yyyy-MM-dd") : undefined;
try {
+ // Skip issues already created in a previous run
+ if (issue.url && progress[issue.url]) {
+ issueCursor++;
+ console.info(` [${issueCursor}/${total}] SKIP (already imported) โ ${issue.title}`);
+ continue;
+ }
+
const createdIssue = await createIssueWithRetries(client, {
teamId,
- projectId: projectId as unknown as string,
+ projectId: projectId ? (projectId as unknown as string) : undefined,
title: issue.title,
description,
priority: issue.priority,
@@ -301,19 +328,83 @@ export const importIssues = async (apiKey: string, importer: Importer, apiUrl?:
await (await createdIssue.issue)?.archive();
}
+ // Upload attachments then replace all Trello URLs with Linear asset URLs in one updateIssue.
+ // We always update the description wholesale so any broken embeds from createIssue are overwritten.
+ if (issue.attachments?.length) {
+ const linearIssue = await createdIssue.issue;
+ if (linearIssue) {
+ let updatedDescription = description ?? "";
+ let descriptionChanged = false;
+
+ for (const att of issue.attachments) {
+ try {
+ const size = att.data.length;
+ const upload = await client.fileUpload(att.mimeType, att.name, size);
+ if (!upload.success || !upload.uploadFile) {
+ continue;
+ }
+
+ const { uploadUrl, assetUrl, headers: uploadHeaders } = upload.uploadFile;
+ const headers: Record = { "Content-Type": att.mimeType, "Content-Length": String(size) };
+ for (const h of uploadHeaders) {
+ headers[h.key] = h.value;
+ }
+
+ const putRes = await fetch(uploadUrl, {
+ method: "PUT",
+ headers,
+ body: att.data,
+ duplex: "half",
+ } as RequestInit);
+ if (!putRes.ok) {
+ continue;
+ }
+
+ const isImage = att.mimeType.startsWith("image/");
+ const replacement = isImage ? `` : `[${att.name}](${assetUrl})`;
+ const trelloUrlPattern = new RegExp(
+ `!?\\[${escapeRegex(att.name)}\\]\\(${escapeRegex(att.trelloUrl)}>?\\)`,
+ "g"
+ );
+ const newDescription = updatedDescription.replace(trelloUrlPattern, replacement);
+ if (newDescription !== updatedDescription) {
+ updatedDescription = newDescription;
+ descriptionChanged = true;
+ }
+ } catch {
+ // Skip failed uploads silently; don't abort the import
+ }
+ }
+
+ if (descriptionChanged) {
+ await client.updateIssue(linearIssue.id, { description: updatedDescription });
+ }
+ }
+ }
+
issueCursor++;
- issuesProgressBar.update(issueCursor);
+ const pct = Math.round((issueCursor / total) * 100);
+ console.info(` [${issueCursor}/${total}] ${pct}% โ ${issue.title}`);
+
+ // Persist progress so we can resume if interrupted
+ if (issue.url) {
+ const linearIssueForProgress = await createdIssue.issue;
+ if (linearIssueForProgress?.id) {
+ progress[issue.url] = linearIssueForProgress.id;
+ writeFileSync(progressFile, JSON.stringify(progress, null, 2));
+ }
+ }
} catch (error) {
- issuesProgressBar.stop();
+ console.error(` โ Failed: ${issue.title} โ ${error.message}`);
throw error;
}
}
- issuesProgressBar.stop();
-
- console.info(chalk.green(`${importer.name} issues imported to your team: https://linear.app/team/${teamKey}/all`));
+ console.info(chalk.green(`โ ${total} issues imported to your team: https://linear.app/team/${teamKey}/all`));
};
+const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
// Build comments into issue description
const buildComments = async (
client: LinearClient,
diff --git a/packages/import/src/importers/trelloJson/TrelloJsonImporter.ts b/packages/import/src/importers/trelloJson/TrelloJsonImporter.ts
index e4181d3e..661036ae 100644
--- a/packages/import/src/importers/trelloJson/TrelloJsonImporter.ts
+++ b/packages/import/src/importers/trelloJson/TrelloJsonImporter.ts
@@ -1,5 +1,24 @@
+/* eslint-disable no-console */
import fs from "fs";
-import type { Comment, Importer, ImportResult } from "../../types.ts";
+import path from "path";
+import type { Comment, Importer, ImportResult, IssueAttachment } from "../../types.ts";
+
+const MIME_TYPES: Record = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+ ".pdf": "application/pdf",
+ ".zip": "application/zip",
+ ".txt": "text/plain",
+};
+
+function getMimeType(filename: string): string {
+ const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
+ return MIME_TYPES[ext] ?? "application/octet-stream";
+}
type TrelloLabelColor = "green" | "yellow" | "orange" | "red" | "purple" | "blue" | "sky" | "lime" | "pink" | "black";
@@ -15,6 +34,7 @@ interface TrelloCard {
color: TrelloLabelColor;
}[];
attachments: {
+ id: string;
name: string;
url: string;
}[];
@@ -55,12 +75,16 @@ export class TrelloJsonImporter implements Importer {
filePath: string,
mapListsToStatuses: boolean,
discardArchivedCards: boolean,
- discardArchivedLists: boolean
+ discardArchivedLists: boolean,
+ trelloApiKey?: string,
+ trelloApiToken?: string
) {
this.filePath = filePath;
this.mapListsToStatuses = mapListsToStatuses;
this.discardArchivedCards = discardArchivedCards;
this.discardArchivedLists = discardArchivedLists;
+ this.trelloApiKey = trelloApiKey;
+ this.trelloApiToken = trelloApiToken;
}
public get name(): string {
@@ -75,9 +99,14 @@ export class TrelloJsonImporter implements Importer {
const bytes = fs.readFileSync(this.filePath);
const data = JSON.parse(bytes as unknown as string);
+ const today = new Date().toISOString().slice(0, 10);
+ const IMPORT_LABEL_ID = `trello-import-${today}`;
+
const importData: ImportResult = {
issues: [],
- labels: {},
+ labels: {
+ [IMPORT_LABEL_ID]: { name: `imported-from-trello-${today}`, color: "#0079BF" },
+ },
users: {},
statuses: {},
};
@@ -119,7 +148,37 @@ export class TrelloJsonImporter implements Importer {
const trelloLists = data.lists as TrelloList[];
+ // Group cards by list and reverse their order within each list
+ const cardsByList = new Map();
for (const card of data.cards as TrelloCard[]) {
+ if (!cardsByList.has(card.idList)) {
+ cardsByList.set(card.idList, []);
+ }
+ cardsByList.get(card.idList)!.push(card);
+ }
+
+ // Reverse cards within each list and flatten back to a single array
+ const sortedCards: TrelloCard[] = [];
+ for (const listId of Array.from(cardsByList.keys())) {
+ const cards = cardsByList.get(listId) ?? [];
+ sortedCards.push(...cards.reverse());
+ }
+
+ // Build a lookup map from the cache dir once โ avoids per-attachment fs.existsSync calls
+ const cacheDir = path.resolve(path.dirname(this.filePath), "attachments-cache");
+ const cacheMap = new Map(); // filename โ full path
+ if (this.trelloApiKey && this.trelloApiToken && fs.existsSync(cacheDir)) {
+ for (const file of fs.readdirSync(cacheDir)) {
+ cacheMap.set(file, path.join(cacheDir, file));
+ }
+ console.info(` Loaded ${cacheMap.size} files from attachment cache\n`);
+ }
+
+ const totalCards = sortedCards.length;
+ let cardCursor = 0;
+ console.info(` Parsing ${totalCards} cards...`);
+
+ for (const card of sortedCards) {
const url = card.shortUrl;
const mdDesc = card.desc;
const cardChecklists = checkLists[card.id] ?? [];
@@ -134,7 +193,14 @@ export class TrelloJsonImporter implements Importer {
)
.join("\n\n---\n\n");
}
+ // Collect URLs already used as inline images in the card description so we
+ // don't duplicate them in the Attachments section below.
+ const inlineImageUrls = new Set();
+ for (const m of (mdDesc ?? "").matchAll(/!\[.*?\]\((https?:\/\/[^)]+)\)/g)) {
+ inlineImageUrls.add(m[1]);
+ }
const formattedAttachments = card.attachments
+ .filter(attachment => !inlineImageUrls.has(attachment.url))
.map(attachment => `[${attachment.name}](${attachment.url})`)
.join("\n");
const cardList = trelloLists.find(list => list.id === card.idList);
@@ -142,7 +208,7 @@ export class TrelloJsonImporter implements Importer {
const description = `${mdDesc}${formattedChecklists && `\n\nChecklists:\n${formattedChecklists}`}${
formattedAttachments && `\n\nAttachments:\n${formattedAttachments}`
}\n\n[View original card in Trello](${url})`;
- const labels = card.labels?.map(l => l.id);
+ const labels = [...(card.labels?.map(l => l.id) ?? []), IMPORT_LABEL_ID];
if (this.discardArchivedCards && card.closed) {
continue;
@@ -152,6 +218,79 @@ export class TrelloJsonImporter implements Importer {
continue;
}
+ // Download attachments if Trello API credentials are available
+ let attachments: IssueAttachment[] | undefined;
+ if (this.trelloApiKey && this.trelloApiToken && card.attachments?.length > 0) {
+ attachments = [];
+ fs.mkdirSync(cacheDir, { recursive: true });
+ let downloaded = 0;
+ let fromCache = 0;
+ for (const att of card.attachments) {
+ try {
+ const cacheFilename = `${card.id}_${att.id}_${att.name}`;
+ const cachedPath = cacheMap.get(cacheFilename);
+
+ let buffer: Buffer;
+ if (cachedPath) {
+ buffer = fs.readFileSync(cachedPath);
+ fromCache++;
+ } else {
+ // Fetch attachment metadata to get the real download URL
+ const metaRes = await fetch(
+ `https://api.trello.com/1/cards/${card.id}/attachments/${att.id}?key=${this.trelloApiKey}&token=${this.trelloApiToken}`
+ );
+ if (!metaRes.ok) {
+ continue;
+ }
+ const meta = (await metaRes.json()) as { url?: string; id?: string };
+ if (!meta.url) {
+ continue;
+ }
+
+ // Download binary with OAuth header
+ const fileRes = await fetch(meta.url, {
+ headers: {
+ Authorization: `OAuth oauth_consumer_key="${this.trelloApiKey}", oauth_token="${this.trelloApiToken}"`,
+ },
+ });
+ if (!fileRes.ok) {
+ continue;
+ }
+
+ const contentType = fileRes.headers.get("content-type") ?? "";
+ buffer = Buffer.from(await fileRes.arrayBuffer());
+
+ // Reject plain-text error bodies
+ if (contentType.startsWith("text/") || contentType.includes("application/json")) {
+ continue;
+ }
+ if (buffer.byteLength < 200 && /^[\x20-\x7E\s]+$/.test(buffer.toString())) {
+ continue;
+ }
+
+ const newCachePath = path.join(cacheDir, cacheFilename);
+ fs.writeFileSync(newCachePath, buffer);
+ cacheMap.set(cacheFilename, newCachePath);
+ downloaded++;
+ }
+
+ attachments.push({
+ trelloUrl: att.url,
+ name: att.name,
+ data: buffer,
+ mimeType: getMimeType(att.name),
+ });
+ } catch {
+ // Skip failed attachments silently; import continues
+ }
+ }
+ if (downloaded > 0 || fromCache > 0) {
+ console.info(
+ ` โ "${card.name}": ${downloaded} downloaded, ${fromCache} from cache (${attachments.length} total)`
+ );
+ }
+ }
+
importData.issues.push({
title: card.name,
description,
@@ -159,9 +298,15 @@ export class TrelloJsonImporter implements Importer {
labels,
comments: comments[card.id],
status: this.mapListsToStatuses ? cardList?.name : undefined,
- archived: card.closed || cardList?.closed,
+ archived: card.closed || cardList?.closed || cardList?.name.toLowerCase() === "closed",
+ attachments: attachments?.length ? attachments : undefined,
});
+ cardCursor++;
+ if (cardCursor % 50 === 0 || cardCursor === totalCards) {
+ console.info(` [${cardCursor}/${totalCards}] cards parsed...`);
+ }
+
const allLabels =
card.labels?.map(label => ({
id: label.id,
@@ -184,6 +329,8 @@ export class TrelloJsonImporter implements Importer {
private mapListsToStatuses: boolean;
private discardArchivedCards: boolean;
private discardArchivedLists: boolean;
+ private trelloApiKey?: string;
+ private trelloApiToken?: string;
}
// Maps Trello colors to Linear branded colors
diff --git a/packages/import/src/importers/trelloJson/index.ts b/packages/import/src/importers/trelloJson/index.ts
index 0208a334..c378df5e 100644
--- a/packages/import/src/importers/trelloJson/index.ts
+++ b/packages/import/src/importers/trelloJson/index.ts
@@ -5,12 +5,55 @@ import { TrelloJsonImporter } from "./TrelloJsonImporter.ts";
const BASE_PATH = process.cwd();
export const trelloJsonImport = async (): Promise => {
+ // Prompt for Trello API credentials only if not already set in environment
+ const credentialQuestions = [];
+ if (!process.env.TRELLO_API_KEY) {
+ credentialQuestions.push({
+ type: "input",
+ name: "trelloApiKey",
+ message: "Trello API key (https://trello.com/app-key):",
+ });
+ }
+ if (!process.env.TRELLO_API_TOKEN) {
+ credentialQuestions.push({
+ type: "password",
+ name: "trelloApiToken",
+ message: "Trello API token:",
+ });
+ }
+ if (!process.env.TRELLO_BOARD_ID) {
+ credentialQuestions.push({
+ type: "input",
+ name: "trelloBoardId",
+ message: "Trello board ID (from the board URL):",
+ });
+ }
+
+ if (credentialQuestions.length > 0) {
+ const credAnswers = await inquirer.prompt<{
+ trelloApiKey?: string;
+ trelloApiToken?: string;
+ trelloBoardId?: string;
+ }>(credentialQuestions);
+ if (credAnswers.trelloApiKey) {
+ process.env.TRELLO_API_KEY = credAnswers.trelloApiKey;
+ }
+ if (credAnswers.trelloApiToken) {
+ process.env.TRELLO_API_TOKEN = credAnswers.trelloApiToken;
+ }
+ if (credAnswers.trelloBoardId) {
+ process.env.TRELLO_BOARD_ID = credAnswers.trelloBoardId;
+ }
+ }
+
const answers = await inquirer.prompt(questions);
const trelloImporter = new TrelloJsonImporter(
answers.trelloFilePath,
answers.mapListsToStatuses,
answers.discardArchivedCards,
- answers.discardArchivedLists
+ answers.discardArchivedLists,
+ process.env.TRELLO_API_KEY,
+ process.env.TRELLO_API_TOKEN
);
return trelloImporter;
};
diff --git a/packages/import/src/types.ts b/packages/import/src/types.ts
index f8526532..0ee88355 100644
--- a/packages/import/src/types.ts
+++ b/packages/import/src/types.ts
@@ -1,5 +1,17 @@
export type IssuePriority = 0 | 1 | 2 | 3 | 4;
+/** A downloaded attachment to be uploaded to Linear. */
+export interface IssueAttachment {
+ /** Original Trello attachment URL (used to replace links in description) */
+ trelloUrl: string;
+ /** Display name */
+ name: string;
+ /** Raw file bytes */
+ data: Buffer;
+ /** MIME type */
+ mimeType: string;
+}
+
/** Issue. */
export interface Issue {
/** Issue title */
@@ -30,6 +42,8 @@ export interface Issue {
archived?: boolean;
/** Issue estimate */
estimate?: number;
+ /** Attachments to upload to Linear */
+ attachments?: IssueAttachment[];
}
/** Issue comment */
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5f791f41..3337a0d0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3,6 +3,7 @@ lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+ injectWorkspacePackages: true
catalogs:
default: