team-toon-tack 1.7.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import fs from "node:fs/promises";
3
+ import path from "node:path";
3
4
  import { decode, encode } from "@toon-format/toon";
4
5
  import prompts from "prompts";
5
6
  import { buildConfig, buildLocalConfig, findTeamKey, findUserKey, getDefaultStatusTransitions, } from "./lib/config-builder.js";
@@ -116,6 +117,40 @@ async function selectTeams(teams, options) {
116
117
  }
117
118
  return { selected: selectedTeams, primary: primaryTeam };
118
119
  }
120
+ async function selectQaPmTeam(teams, primaryTeam, options) {
121
+ // Only ask if there are multiple teams and interactive mode
122
+ if (!options.interactive || teams.length <= 1) {
123
+ return undefined;
124
+ }
125
+ // Filter out primary team from choices
126
+ const otherTeams = teams.filter((t) => t.id !== primaryTeam.id);
127
+ if (otherTeams.length === 0) {
128
+ return undefined;
129
+ }
130
+ console.log("\nšŸ”— QA/PM Team Configuration:");
131
+ const response = await prompts({
132
+ type: "select",
133
+ name: "qaPmTeamId",
134
+ message: "Select QA/PM team (for cross-team parent issue updates):",
135
+ choices: [
136
+ {
137
+ title: "(None - skip)",
138
+ value: undefined,
139
+ description: "No cross-team parent updates",
140
+ },
141
+ ...otherTeams.map((t) => ({
142
+ title: t.name,
143
+ value: t.id,
144
+ description: "Parent issues in this team will be updated to Testing",
145
+ })),
146
+ ],
147
+ initial: 0,
148
+ });
149
+ if (!response.qaPmTeamId) {
150
+ return undefined;
151
+ }
152
+ return teams.find((t) => t.id === response.qaPmTeamId);
153
+ }
119
154
  async function selectUser(users, options) {
120
155
  let currentUser = users[0];
121
156
  if (options.user) {
@@ -157,6 +192,31 @@ async function selectLabelFilter(labels, options) {
157
192
  }
158
193
  return undefined;
159
194
  }
195
+ async function selectStatusSource(options) {
196
+ if (!options.interactive) {
197
+ return "remote"; // default
198
+ }
199
+ console.log("\nšŸ”„ Configure status sync mode:");
200
+ const response = await prompts({
201
+ type: "select",
202
+ name: "statusSource",
203
+ message: "Where should status updates be stored?",
204
+ choices: [
205
+ {
206
+ title: "Remote (recommended)",
207
+ value: "remote",
208
+ description: "Update Linear immediately when you work-on or complete tasks",
209
+ },
210
+ {
211
+ title: "Local",
212
+ value: "local",
213
+ description: "Work offline, then sync to Linear with 'sync --update'",
214
+ },
215
+ ],
216
+ initial: 0,
217
+ });
218
+ return response.statusSource || "remote";
219
+ }
160
220
  async function selectStatusMappings(states, options) {
161
221
  const defaults = getDefaultStatusTransitions(states);
162
222
  if (!options.interactive || states.length === 0) {
@@ -201,13 +261,215 @@ async function selectStatusMappings(states, options) {
201
261
  ? testingChoices.findIndex((c) => c.value === defaults.testing)
202
262
  : 0,
203
263
  });
264
+ const blockedChoices = [
265
+ { title: "(None)", value: undefined },
266
+ ...stateChoices,
267
+ ];
268
+ const blockedResponse = await prompts({
269
+ type: "select",
270
+ name: "blocked",
271
+ message: 'Select status for "Blocked" (optional, for blocked tasks):',
272
+ choices: blockedChoices,
273
+ initial: defaults.blocked
274
+ ? blockedChoices.findIndex((c) => c.value === defaults.blocked)
275
+ : 0,
276
+ });
204
277
  return {
205
278
  todo: todoResponse.todo || defaults.todo,
206
279
  in_progress: inProgressResponse.in_progress || defaults.in_progress,
207
280
  done: doneResponse.done || defaults.done,
208
281
  testing: testingResponse.testing,
282
+ blocked: blockedResponse.blocked,
209
283
  };
210
284
  }
285
+ async function updateGitignore(tttDir, interactive) {
286
+ const gitignorePath = ".gitignore";
287
+ const entry = `${tttDir}/`;
288
+ try {
289
+ let content = "";
290
+ let exists = false;
291
+ try {
292
+ content = await fs.readFile(gitignorePath, "utf-8");
293
+ exists = true;
294
+ }
295
+ catch {
296
+ // .gitignore doesn't exist
297
+ }
298
+ // Check if already ignored
299
+ const lines = content.split("\n");
300
+ const alreadyIgnored = lines.some((line) => line.trim() === entry ||
301
+ line.trim() === tttDir ||
302
+ line.trim() === `/${entry}` ||
303
+ line.trim() === `/${tttDir}`);
304
+ if (alreadyIgnored) {
305
+ return;
306
+ }
307
+ // Ask user in interactive mode
308
+ if (interactive) {
309
+ const { addToGitignore } = await prompts({
310
+ type: "confirm",
311
+ name: "addToGitignore",
312
+ message: `Add ${entry} to .gitignore?`,
313
+ initial: true,
314
+ });
315
+ if (!addToGitignore)
316
+ return;
317
+ }
318
+ // Add to .gitignore
319
+ const newContent = exists
320
+ ? content.endsWith("\n")
321
+ ? `${content}${entry}\n`
322
+ : `${content}\n${entry}\n`
323
+ : `${entry}\n`;
324
+ await fs.writeFile(gitignorePath, newContent, "utf-8");
325
+ console.log(` āœ“ Added ${entry} to .gitignore`);
326
+ }
327
+ catch (_error) {
328
+ // Silently ignore gitignore errors
329
+ }
330
+ }
331
+ async function installClaudeCommands(interactive, statusSource) {
332
+ if (!interactive) {
333
+ return { installed: false, prefix: "" };
334
+ }
335
+ console.log("\nšŸ¤– Claude Code Commands:");
336
+ // Ask if user wants to install commands
337
+ const { install } = await prompts({
338
+ type: "confirm",
339
+ name: "install",
340
+ message: "Install Claude Code commands? (work-on, done-job, sync-linear)",
341
+ initial: true,
342
+ });
343
+ if (!install) {
344
+ return { installed: false, prefix: "" };
345
+ }
346
+ // Ask for prefix
347
+ const { prefixChoice } = await prompts({
348
+ type: "select",
349
+ name: "prefixChoice",
350
+ message: "Command prefix style:",
351
+ choices: [
352
+ {
353
+ title: "No prefix (recommended)",
354
+ value: "",
355
+ description: "/work-on, /done-job, /sync-linear",
356
+ },
357
+ {
358
+ title: "ttt:",
359
+ value: "ttt:",
360
+ description: "/ttt:work-on, /ttt:done-job, /ttt:sync-linear",
361
+ },
362
+ {
363
+ title: "linear:",
364
+ value: "linear:",
365
+ description: "/linear:work-on, /linear:done-job, /linear:sync-linear",
366
+ },
367
+ {
368
+ title: "Custom...",
369
+ value: "custom",
370
+ description: "Enter your own prefix",
371
+ },
372
+ ],
373
+ initial: 0,
374
+ });
375
+ let prefix = prefixChoice || "";
376
+ if (prefixChoice === "custom") {
377
+ const { customPrefix } = await prompts({
378
+ type: "text",
379
+ name: "customPrefix",
380
+ message: "Enter custom prefix (e.g., 'my:'):",
381
+ initial: "",
382
+ });
383
+ prefix = customPrefix || "";
384
+ }
385
+ // Find templates directory
386
+ // Try multiple locations: installed package, local dev
387
+ const possibleTemplatePaths = [
388
+ path.join(__dirname, "..", "templates", "claude-code-commands"),
389
+ path.join(__dirname, "..", "..", "templates", "claude-code-commands"),
390
+ path.join(process.cwd(), "templates", "claude-code-commands"),
391
+ ];
392
+ let templateDir = null;
393
+ for (const p of possibleTemplatePaths) {
394
+ try {
395
+ await fs.access(p);
396
+ templateDir = p;
397
+ break;
398
+ }
399
+ catch {
400
+ // Try next path
401
+ }
402
+ }
403
+ if (!templateDir) {
404
+ // Try to get repo URL from package.json
405
+ let repoUrl = "https://github.com/wayne930242/team-toon-tack";
406
+ try {
407
+ const pkgPaths = [
408
+ path.join(__dirname, "..", "package.json"),
409
+ path.join(__dirname, "..", "..", "package.json"),
410
+ ];
411
+ for (const pkgPath of pkgPaths) {
412
+ try {
413
+ const pkgContent = await fs.readFile(pkgPath, "utf-8");
414
+ const pkg = JSON.parse(pkgContent);
415
+ if (pkg.repository?.url) {
416
+ // Parse git+https://github.com/user/repo.git format
417
+ repoUrl = pkg.repository.url
418
+ .replace(/^git\+/, "")
419
+ .replace(/\.git$/, "");
420
+ }
421
+ break;
422
+ }
423
+ catch {
424
+ // Try next path
425
+ }
426
+ }
427
+ }
428
+ catch {
429
+ // Use default URL
430
+ }
431
+ console.log(" ⚠ Could not find command templates. Please copy manually from:");
432
+ console.log(` ${repoUrl}/tree/main/templates/claude-code-commands`);
433
+ return { installed: false, prefix };
434
+ }
435
+ // Create .claude/commands directory
436
+ const commandsDir = path.join(process.cwd(), ".claude", "commands");
437
+ await fs.mkdir(commandsDir, { recursive: true });
438
+ // Copy and rename template files
439
+ const templateFiles = await fs.readdir(templateDir);
440
+ const commandFiles = templateFiles.filter((f) => f.endsWith(".md"));
441
+ for (const file of commandFiles) {
442
+ const baseName = file.replace(".md", "");
443
+ const newFileName = prefix ? `${prefix}${baseName}.md` : file;
444
+ const srcPath = path.join(templateDir, file);
445
+ const destPath = path.join(commandsDir, newFileName);
446
+ // Read template content
447
+ let content = await fs.readFile(srcPath, "utf-8");
448
+ // Update the name in frontmatter if prefix is used
449
+ if (prefix) {
450
+ content = content.replace(/^(---\s*\n[\s\S]*?name:\s*)(\S+)/m, `$1${prefix}${baseName}`);
451
+ }
452
+ // Modify content based on statusSource for work-on and done-job
453
+ if (statusSource === "local") {
454
+ if (baseName === "work-on" || baseName.endsWith("work-on")) {
455
+ // Update description for local mode
456
+ content = content.replace(/Select a task and update status to "In Progress" on both local and Linear\./, 'Select a task and update local status to "In Progress". (Linear will be updated when you run `sync --update`)');
457
+ // Add reminder after Complete section
458
+ content = content.replace(/Use `?\/done-job`? to mark task as completed/, "Use `/done-job` to mark task as completed\n\n### 7. Sync to Linear\n\nWhen ready to update Linear with all your changes:\n\n```bash\nttt sync --update\n```");
459
+ }
460
+ if (baseName === "done-job" || baseName.endsWith("done-job")) {
461
+ // Update description for local mode
462
+ content = content.replace(/Mark a task as done and update Linear with commit details\./, "Mark a task as done locally. (Run `ttt sync --update` to push changes to Linear)");
463
+ // Add reminder at the end
464
+ content = content.replace(/## What It Does\n\n- Linear issue status → "Done"/, "## What It Does\n\n- Local status → `completed`");
465
+ content += `\n## Sync to Linear\n\nAfter completing tasks, push all changes to Linear:\n\n\`\`\`bash\nttt sync --update\n\`\`\`\n`;
466
+ }
467
+ }
468
+ await fs.writeFile(destPath, content, "utf-8");
469
+ console.log(` āœ“ .claude/commands/${newFileName}`);
470
+ }
471
+ return { installed: true, prefix };
472
+ }
211
473
  async function init() {
212
474
  const args = process.argv.slice(2);
213
475
  const options = parseArgs(args);
@@ -262,24 +524,62 @@ async function init() {
262
524
  if (selectedTeams.length > 1) {
263
525
  console.log(` Primary: ${primaryTeam.name}`);
264
526
  }
265
- // Fetch data from primary team
266
- const selectedTeam = await client.team(primaryTeam.id);
267
- const members = await selectedTeam.members();
268
- const users = members.nodes;
527
+ // Fetch data from ALL teams (not just primary) to support cross-team operations
528
+ console.log(` Fetching data from ${teams.length} teams...`);
529
+ // Collect users, labels, states from all teams
530
+ const allUsers = [];
531
+ const allLabels = [];
532
+ const allStates = [];
533
+ const seenUserIds = new Set();
534
+ const seenLabelIds = new Set();
535
+ const seenStateIds = new Set();
536
+ for (const team of teams) {
537
+ try {
538
+ const teamData = await client.team(team.id);
539
+ const members = await teamData.members();
540
+ for (const user of members.nodes) {
541
+ if (!seenUserIds.has(user.id)) {
542
+ seenUserIds.add(user.id);
543
+ allUsers.push(user);
544
+ }
545
+ }
546
+ const labelsData = await client.issueLabels({
547
+ filter: { team: { id: { eq: team.id } } },
548
+ });
549
+ for (const label of labelsData.nodes) {
550
+ if (!seenLabelIds.has(label.id)) {
551
+ seenLabelIds.add(label.id);
552
+ allLabels.push(label);
553
+ }
554
+ }
555
+ const statesData = await client.workflowStates({
556
+ filter: { team: { id: { eq: team.id } } },
557
+ });
558
+ for (const state of statesData.nodes) {
559
+ if (!seenStateIds.has(state.id)) {
560
+ seenStateIds.add(state.id);
561
+ allStates.push(state);
562
+ }
563
+ }
564
+ }
565
+ catch (error) {
566
+ console.warn(` ⚠ Could not fetch data for team ${team.name}, skipping...`);
567
+ }
568
+ }
569
+ const users = allUsers;
570
+ const labels = allLabels;
571
+ const states = allStates;
269
572
  console.log(` Users: ${users.length}`);
270
- const labelsData = await client.issueLabels({
271
- filter: { team: { id: { eq: primaryTeam.id } } },
272
- });
273
- const labels = labelsData.nodes;
274
573
  console.log(` Labels: ${labels.length}`);
275
- const statesData = await client.workflowStates({
276
- filter: { team: { id: { eq: primaryTeam.id } } },
277
- });
278
- const states = statesData.nodes;
574
+ console.log(` Workflow states: ${states.length}`);
575
+ // Get cycle from primary team (for current work tracking)
576
+ const selectedTeam = await client.team(primaryTeam.id);
279
577
  const currentCycle = (await selectedTeam.activeCycle);
280
578
  // User selections
281
579
  const currentUser = await selectUser(users, options);
282
580
  const defaultLabel = await selectLabelFilter(labels, options);
581
+ const statusSource = await selectStatusSource(options);
582
+ const qaPmTeam = await selectQaPmTeam(teams, primaryTeam, options);
283
583
  const statusTransitions = await selectStatusMappings(states, options);
284
584
  // Build config
285
585
  const config = buildConfig(teams, users, labels, states, statusTransitions, currentCycle ?? undefined);
@@ -289,7 +589,11 @@ async function init() {
289
589
  const selectedTeamKeys = selectedTeams
290
590
  .map((team) => findTeamKey(config.teams, team.id))
291
591
  .filter((key) => key !== undefined);
292
- const localConfig = buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel);
592
+ const qaPmTeamKey = qaPmTeam
593
+ ? findTeamKey(config.teams, qaPmTeam.id)
594
+ : undefined;
595
+ const localConfig = buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, undefined, // excludeLabels
596
+ statusSource, qaPmTeamKey);
293
597
  // Write config files
294
598
  console.log("\nšŸ“ Writing configuration files...");
295
599
  await fs.mkdir(paths.baseDir, { recursive: true });
@@ -326,10 +630,14 @@ async function init() {
326
630
  localConfig.team = existingLocal.team;
327
631
  if (existingLocal.teams)
328
632
  localConfig.teams = existingLocal.teams;
633
+ if (existingLocal.qa_pm_team)
634
+ localConfig.qa_pm_team = existingLocal.qa_pm_team;
329
635
  if (existingLocal.label)
330
636
  localConfig.label = existingLocal.label;
331
637
  if (existingLocal.exclude_labels)
332
638
  localConfig.exclude_labels = existingLocal.exclude_labels;
639
+ if (existingLocal.status_source)
640
+ localConfig.status_source = existingLocal.status_source;
333
641
  }
334
642
  }
335
643
  catch {
@@ -338,6 +646,11 @@ async function init() {
338
646
  }
339
647
  await fs.writeFile(paths.localPath, encode(localConfig), "utf-8");
340
648
  console.log(` āœ“ ${paths.localPath}`);
649
+ // Update .gitignore
650
+ const tttDir = paths.baseDir.replace(/^\.\//, "");
651
+ await updateGitignore(tttDir, options.interactive ?? true);
652
+ // Install Claude Code commands
653
+ const { installed: commandsInstalled, prefix: commandPrefix } = await installClaudeCommands(options.interactive ?? true, statusSource);
341
654
  // Summary
342
655
  console.log("\nāœ… Initialization complete!\n");
343
656
  console.log("Configuration summary:");
@@ -347,6 +660,10 @@ async function init() {
347
660
  }
348
661
  console.log(` User: ${currentUser.displayName || currentUser.name} (${currentUser.email})`);
349
662
  console.log(` Label filter: ${defaultLabel || "(none)"}`);
663
+ console.log(` Status source: ${statusSource === "local" ? "local (use 'sync --update' to push)" : "remote (immediate sync)"}`);
664
+ if (qaPmTeam) {
665
+ console.log(` QA/PM team: ${qaPmTeam.name}`);
666
+ }
350
667
  console.log(` (Use 'ttt config filters' to set excluded labels/users)`);
351
668
  if (currentCycle) {
352
669
  console.log(` Cycle: ${currentCycle.name || `Cycle #${currentCycle.number}`}`);
@@ -358,10 +675,24 @@ async function init() {
358
675
  if (statusTransitions.testing) {
359
676
  console.log(` Testing: ${statusTransitions.testing}`);
360
677
  }
678
+ if (statusTransitions.blocked) {
679
+ console.log(` Blocked: ${statusTransitions.blocked}`);
680
+ }
681
+ if (commandsInstalled) {
682
+ const cmdPrefix = commandPrefix ? `${commandPrefix}` : "";
683
+ console.log(` Claude commands: /${cmdPrefix}work-on, /${cmdPrefix}done-job, /${cmdPrefix}sync-linear`);
684
+ }
361
685
  console.log("\nNext steps:");
362
686
  console.log(" 1. Set LINEAR_API_KEY in your shell profile:");
363
687
  console.log(` export LINEAR_API_KEY="${apiKey}"`);
364
- console.log(" 2. Run sync: bun run sync");
365
- console.log(" 3. Start working: bun run work-on");
688
+ console.log(" 2. Run sync: ttt sync");
689
+ if (commandsInstalled) {
690
+ const cmdPrefix = commandPrefix ? `${commandPrefix}` : "";
691
+ console.log(` 3. In Claude Code: /${cmdPrefix}work-on next`);
692
+ console.log(`\nšŸ’” Tip: Edit .claude/commands/${cmdPrefix}work-on.md to customize the "Verify" section for your project.`);
693
+ }
694
+ else {
695
+ console.log(" 3. Start working: ttt work-on");
696
+ }
366
697
  }
367
698
  init().catch(console.error);
@@ -38,4 +38,4 @@ export declare function getDefaultStatusTransitions(states: LinearState[]): Stat
38
38
  export declare function buildConfig(teams: LinearTeam[], users: LinearUser[], labels: LinearLabel[], states: LinearState[], statusTransitions: StatusTransitions, currentCycle?: LinearCycle): Config;
39
39
  export declare function findUserKey(usersConfig: Record<string, UserConfig>, userId: string): string;
40
40
  export declare function findTeamKey(teamsConfig: Record<string, TeamConfig>, teamId: string): string;
41
- export declare function buildLocalConfig(currentUserKey: string, primaryTeamKey: string, selectedTeamKeys: string[], defaultLabel?: string, excludeLabels?: string[]): LocalConfig;
41
+ export declare function buildLocalConfig(currentUserKey: string, primaryTeamKey: string, selectedTeamKeys: string[], defaultLabel?: string, excludeLabels?: string[], statusSource?: "remote" | "local", qaPmTeam?: string): LocalConfig;
@@ -67,11 +67,13 @@ export function getDefaultStatusTransitions(states) {
67
67
  findStatusByKeyword(states, ["done", "complete"]) ||
68
68
  "Done";
69
69
  const defaultTesting = findStatusByKeyword(states, ["testing", "review"]) || undefined;
70
+ const defaultBlocked = findStatusByKeyword(states, ["blocked", "on hold", "waiting"]) || undefined;
70
71
  return {
71
72
  todo: defaultTodo,
72
73
  in_progress: defaultInProgress,
73
74
  done: defaultDone,
74
75
  testing: defaultTesting,
76
+ blocked: defaultBlocked,
75
77
  };
76
78
  }
77
79
  export function buildConfig(teams, users, labels, states, statusTransitions, currentCycle) {
@@ -106,12 +108,14 @@ export function findTeamKey(teamsConfig, teamId) {
106
108
  return (Object.entries(teamsConfig).find(([_, t]) => t.id === teamId)?.[0] ||
107
109
  Object.keys(teamsConfig)[0]);
108
110
  }
109
- export function buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, excludeLabels) {
111
+ export function buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, excludeLabels, statusSource, qaPmTeam) {
110
112
  return {
111
113
  current_user: currentUserKey,
112
114
  team: primaryTeamKey,
113
115
  teams: selectedTeamKeys.length > 1 ? selectedTeamKeys : undefined,
116
+ qa_pm_team: qaPmTeam,
114
117
  label: defaultLabel,
115
118
  exclude_labels: excludeLabels && excludeLabels.length > 0 ? excludeLabels : undefined,
119
+ status_source: statusSource,
116
120
  };
117
121
  }
@@ -11,7 +11,7 @@ export function getStatusIcon(localStatus) {
11
11
  return "āœ…";
12
12
  case "in-progress":
13
13
  return "šŸ”„";
14
- case "blocked-backend":
14
+ case "blocked":
15
15
  return "🚫";
16
16
  default:
17
17
  return "šŸ“‹";
@@ -39,14 +39,25 @@ export function displayTaskStatus(task) {
39
39
  }
40
40
  export function displayTaskDescription(task) {
41
41
  if (task.description) {
42
- console.log(`\nšŸ“ Description:\n${task.description}`);
42
+ let description = task.description;
43
+ // Replace Linear URLs with local paths from attachments
44
+ if (task.attachments) {
45
+ for (const att of task.attachments) {
46
+ if (att.localPath && att.url) {
47
+ description = description.split(att.url).join(att.localPath);
48
+ }
49
+ }
50
+ }
51
+ console.log(`\nšŸ“ Description:\n${description}`);
43
52
  }
44
53
  }
45
54
  export function displayTaskAttachments(task) {
46
55
  if (task.attachments && task.attachments.length > 0) {
47
56
  console.log(`\nšŸ“Ž Attachments:`);
48
57
  for (const att of task.attachments) {
49
- console.log(` - ${att.title}: ${att.url}`);
58
+ // Prefer local path for Linear images (Linear URLs are not accessible)
59
+ const displayPath = att.localPath || att.url;
60
+ console.log(` - ${att.title}: ${displayPath}`);
50
61
  }
51
62
  }
52
63
  }
@@ -0,0 +1,9 @@
1
+ export declare function isLinearImageUrl(url: string): boolean;
2
+ /**
3
+ * Extract Linear image URLs from markdown text (description, comments)
4
+ */
5
+ export declare function extractLinearImageUrls(text: string): string[];
6
+ export declare function downloadLinearFile(url: string, issueId: string, attachmentId: string, outputDir: string): Promise<string | undefined>;
7
+ export declare const downloadLinearImage: typeof downloadLinearFile;
8
+ export declare function clearIssueImages(outputDir: string, issueId: string): Promise<void>;
9
+ export declare function ensureOutputDir(outputDir: string): Promise<void>;
@@ -0,0 +1,107 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const LINEAR_IMAGE_DOMAINS = [
4
+ "uploads.linear.app",
5
+ "linear-uploads.s3.us-west-2.amazonaws.com",
6
+ ];
7
+ export function isLinearImageUrl(url) {
8
+ try {
9
+ const parsed = new URL(url);
10
+ return LINEAR_IMAGE_DOMAINS.some((domain) => parsed.host.includes(domain));
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /**
17
+ * Extract Linear image URLs from markdown text (description, comments)
18
+ */
19
+ export function extractLinearImageUrls(text) {
20
+ const urls = [];
21
+ // Match markdown image syntax ![alt](url) and plain URLs
22
+ const patterns = [
23
+ /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/g, // ![alt](url)
24
+ /(https?:\/\/uploads\.linear\.app\/[^\s)>\]]+)/g, // Plain Linear upload URLs
25
+ ];
26
+ for (const pattern of patterns) {
27
+ let match;
28
+ while ((match = pattern.exec(text)) !== null) {
29
+ const url = match[1];
30
+ if (isLinearImageUrl(url) && !urls.includes(url)) {
31
+ urls.push(url);
32
+ }
33
+ }
34
+ }
35
+ return urls;
36
+ }
37
+ function getFileExtension(url, contentType) {
38
+ // Try to get extension from content-type header
39
+ if (contentType) {
40
+ // Image types
41
+ const imageMatch = contentType.match(/image\/(\w+)/);
42
+ if (imageMatch) {
43
+ const ext = imageMatch[1] === "jpeg" ? "jpg" : imageMatch[1];
44
+ return { ext, isImage: true };
45
+ }
46
+ // Video types
47
+ const videoMatch = contentType.match(/video\/(\w+)/);
48
+ if (videoMatch) {
49
+ const videoExt = videoMatch[1] === "quicktime" ? "mov" : videoMatch[1];
50
+ return { ext: videoExt, isImage: false };
51
+ }
52
+ }
53
+ // Fallback: try to get from URL path
54
+ const urlPath = new URL(url).pathname;
55
+ const ext = path.extname(urlPath).slice(1).toLowerCase();
56
+ if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
57
+ return { ext: ext === "jpeg" ? "jpg" : ext, isImage: true };
58
+ }
59
+ if (["mov", "mp4", "webm", "avi"].includes(ext)) {
60
+ return { ext, isImage: false };
61
+ }
62
+ return { ext: "png", isImage: true }; // Default assume image
63
+ }
64
+ export async function downloadLinearFile(url, issueId, attachmentId, outputDir) {
65
+ try {
66
+ // Linear files require authentication
67
+ const headers = {};
68
+ if (process.env.LINEAR_API_KEY) {
69
+ headers["Authorization"] = process.env.LINEAR_API_KEY;
70
+ }
71
+ const response = await fetch(url, { headers });
72
+ if (!response.ok) {
73
+ console.error(`Failed to download file: ${response.status}`);
74
+ return undefined;
75
+ }
76
+ const contentType = response.headers.get("content-type") || undefined;
77
+ const { ext } = getFileExtension(url, contentType);
78
+ const filename = `${issueId}_${attachmentId}.${ext}`;
79
+ const filepath = path.join(outputDir, filename);
80
+ const buffer = await response.arrayBuffer();
81
+ await fs.writeFile(filepath, Buffer.from(buffer));
82
+ return filepath;
83
+ }
84
+ catch (error) {
85
+ console.error(`Error downloading file: ${error}`);
86
+ return undefined;
87
+ }
88
+ }
89
+ // Alias for backwards compatibility
90
+ export const downloadLinearImage = downloadLinearFile;
91
+ export async function clearIssueImages(outputDir, issueId) {
92
+ try {
93
+ const files = await fs.readdir(outputDir);
94
+ const issuePrefix = `${issueId}_`;
95
+ for (const file of files) {
96
+ if (file.startsWith(issuePrefix)) {
97
+ await fs.unlink(path.join(outputDir, file));
98
+ }
99
+ }
100
+ }
101
+ catch {
102
+ // Directory doesn't exist or other error, ignore
103
+ }
104
+ }
105
+ export async function ensureOutputDir(outputDir) {
106
+ await fs.mkdir(outputDir, { recursive: true });
107
+ }
@@ -8,4 +8,4 @@ export declare function getWorkflowStates(config: Config, teamKey: string): Prom
8
8
  export declare function getStatusTransitions(config: Config): StatusTransitions;
9
9
  export declare function updateIssueStatus(linearId: string, targetStatusName: string, config: Config, teamKey: string): Promise<boolean>;
10
10
  export declare function addComment(issueId: string, body: string): Promise<boolean>;
11
- export declare function mapLocalStatusToLinear(localStatus: "pending" | "in-progress" | "completed" | "blocked-backend", config: Config): string | undefined;
11
+ export declare function mapLocalStatusToLinear(localStatus: "pending" | "in-progress" | "completed" | "blocked", config: Config): string | undefined;
@@ -55,6 +55,8 @@ export function mapLocalStatusToLinear(localStatus, config) {
55
55
  return transitions.in_progress;
56
56
  case "completed":
57
57
  return transitions.done;
58
+ case "blocked":
59
+ return transitions.blocked;
58
60
  default:
59
61
  return undefined;
60
62
  }