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)}\\]\\(?\\)`, "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})` : `[${att.name}](${assetUrl})`; + const pattern = new RegExp(`!?\\[${escapeRegex(att.name)}\\]\\(?\\)`, "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})` : `[${att.name}](${assetUrl})`; + const trelloUrlPattern = new RegExp( + `!?\\[${escapeRegex(att.name)}\\]\\(?\\)`, + "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: