team-toon-tack 3.2.10 → 3.2.12

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.
@@ -105,6 +105,7 @@ async function doneJob() {
105
105
  const statusSource = localConfig.status_source || "remote";
106
106
  const sourceType = getSourceType(config);
107
107
  const sourceId = task.sourceId ?? task.linearId;
108
+ let completionResult = null;
108
109
  if (sourceId && statusSource === "remote") {
109
110
  // Build completion context
110
111
  const context = {
@@ -116,10 +117,15 @@ async function doneJob() {
116
117
  };
117
118
  // Branch based on source type
118
119
  if (sourceType === "trello") {
119
- await handleTrelloCompletion(context);
120
+ completionResult = await handleTrelloCompletion(context);
120
121
  }
121
122
  else {
122
- await handleLinearCompletion(context);
123
+ completionResult = await handleLinearCompletion(context);
124
+ }
125
+ if (!completionResult.success) {
126
+ console.error(completionResult.message ||
127
+ `Failed to update remote status for ${task.id}.`);
128
+ process.exit(1);
123
129
  }
124
130
  }
125
131
  else if (statusSource === "local") {
@@ -136,6 +142,11 @@ async function doneJob() {
136
142
  });
137
143
  if (syncedTask) {
138
144
  console.log(`Synced: ${syncedTask.id} → ${syncedTask.status} (local: ${syncedTask.localStatus})`);
145
+ if (completionResult?.status &&
146
+ syncedTask.status !== completionResult.status) {
147
+ console.error(`Remote status verification failed for ${task.id}: expected ${completionResult.status}, got ${syncedTask.status}.`);
148
+ process.exit(1);
149
+ }
139
150
  }
140
151
  }
141
152
  else {
@@ -208,7 +208,15 @@ export class LinearAdapter {
208
208
  }
209
209
  async updateIssueStatus(sourceId, statusId) {
210
210
  try {
211
- await this.client.updateIssue(sourceId, { stateId: statusId });
211
+ const payload = await this.client.updateIssue(sourceId, {
212
+ stateId: statusId,
213
+ });
214
+ if (!payload.success) {
215
+ return {
216
+ success: false,
217
+ error: `Linear mutation returned success=false for ${sourceId}`,
218
+ };
219
+ }
212
220
  return { success: true };
213
221
  }
214
222
  catch (e) {
@@ -16,6 +16,13 @@ async function handleSimpleCompletion(context) {
16
16
  if (success) {
17
17
  console.log(`Linear: ${task.id} → ${transitions.done}`);
18
18
  }
19
+ else {
20
+ return {
21
+ success: false,
22
+ status: task.status,
23
+ message: `Failed to move ${task.id} to ${transitions.done}`,
24
+ };
25
+ }
19
26
  // Also mark parent as done if exists
20
27
  if (task.parentIssueId) {
21
28
  const result = await updateParentStatus(task.parentIssueId, transitions.done, localConfig.qa_pm_teams, config);
@@ -39,6 +46,13 @@ async function handleStrictReview(context) {
39
46
  if (success) {
40
47
  console.log(`Linear: ${task.id} → ${devTestingStatus}`);
41
48
  }
49
+ else {
50
+ return {
51
+ success: false,
52
+ status: task.status,
53
+ message: `Failed to move ${task.id} to ${devTestingStatus}`,
54
+ };
55
+ }
42
56
  // Also mark parent to testing if exists
43
57
  if (task.parentIssueId && localConfig.qa_pm_teams?.length) {
44
58
  const result = await updateParentToTesting(task.parentIssueId, localConfig.qa_pm_teams, config);
@@ -54,6 +68,13 @@ async function handleStrictReview(context) {
54
68
  if (success) {
55
69
  console.log(`Linear: ${task.id} → ${transitions.done}`);
56
70
  }
71
+ else {
72
+ return {
73
+ success: false,
74
+ status: task.status,
75
+ message: `Failed to move ${task.id} to ${transitions.done}`,
76
+ };
77
+ }
57
78
  return { success: true, status: transitions.done };
58
79
  }
59
80
  /**
@@ -87,8 +108,20 @@ async function handleUpstreamCompletion(context, isStrict) {
87
108
  const fallbackSuccess = await updateIssueStatus(task.linearId, devTestingStatus, config, localConfig.team);
88
109
  if (fallbackSuccess) {
89
110
  console.log(`Linear: ${task.id} → ${devTestingStatus} (fallback, no valid parent)`);
111
+ return { success: true, status: devTestingStatus };
90
112
  }
91
- return { success: true, status: devTestingStatus };
113
+ return {
114
+ success: false,
115
+ status: task.status,
116
+ message: `Failed to move ${task.id} to ${devTestingStatus}`,
117
+ };
118
+ }
119
+ if (!doneSuccess) {
120
+ return {
121
+ success: false,
122
+ status: task.status,
123
+ message: `Failed to move ${task.id} to ${transitions.done}`,
124
+ };
92
125
  }
93
126
  return { success: true, status: transitions.done };
94
127
  }
@@ -121,7 +154,7 @@ export async function handleLinearCompletion(context) {
121
154
  result = await handleSimpleCompletion(context);
122
155
  }
123
156
  // Add comment with commit info (only if promptMessage provided)
124
- if (commit && promptMessage) {
157
+ if (result.success && commit && promptMessage) {
125
158
  const commentBody = buildCompletionComment(commit, promptMessage);
126
159
  const commentSuccess = await addComment(task.linearId, commentBody);
127
160
  if (commentSuccess) {
@@ -31,6 +31,12 @@ export async function handleTrelloCompletion(context) {
31
31
  if (result.success) {
32
32
  console.log(`Trello: ${task.id} → ${transitions.done}`);
33
33
  }
34
+ else {
35
+ return {
36
+ success: false,
37
+ message: result.error || `Failed to move ${task.id} to ${transitions.done}`,
38
+ };
39
+ }
34
40
  // Add comment with commit info (only if promptMessage provided)
35
41
  if (commit && promptMessage) {
36
42
  const commentBody = buildCompletionComment(commit, promptMessage);
@@ -1,9 +1,19 @@
1
1
  export declare function isLinearImageUrl(url: string): boolean;
2
+ export declare function isTrelloFileUrl(url: string): boolean;
3
+ /**
4
+ * Extract image/file URLs from markdown text (description, comments)
5
+ */
6
+ export declare function extractImageUrls(text: string): string[];
2
7
  /**
3
8
  * Extract Linear image URLs from markdown text (description, comments)
4
9
  */
5
10
  export declare function extractLinearImageUrls(text: string): string[];
11
+ interface TrelloCredentials {
12
+ apiKey?: string;
13
+ token?: string;
14
+ }
6
15
  export declare function downloadLinearFile(url: string, issueId: string, attachmentId: string, outputDir: string): Promise<string | undefined>;
16
+ export declare function downloadTrelloFile(url: string, issueId: string, attachmentId: string, outputDir: string, credentials?: TrelloCredentials): Promise<string | undefined>;
7
17
  export declare const downloadLinearImage: typeof downloadLinearFile;
8
18
  export declare function clearIssueImages(outputDir: string, issueId: string): Promise<void>;
9
19
  export declare function ensureOutputDir(outputDir: string): Promise<void>;
@@ -11,3 +21,4 @@ export declare function ensureOutputDir(outputDir: string): Promise<void>;
11
21
  * Clear all files in output directory (for fresh sync)
12
22
  */
13
23
  export declare function clearAllOutput(outputDir: string): Promise<void>;
24
+ export {};
@@ -4,6 +4,12 @@ const LINEAR_IMAGE_DOMAINS = [
4
4
  "uploads.linear.app",
5
5
  "linear-uploads.s3.us-west-2.amazonaws.com",
6
6
  ];
7
+ const TRELLO_FILE_DOMAINS = [
8
+ "trello.com",
9
+ "api.trello.com",
10
+ "attachments.trello.com",
11
+ ];
12
+ const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg)(?:$|[?#])/i;
7
13
  export function isLinearImageUrl(url) {
8
14
  try {
9
15
  const parsed = new URL(url);
@@ -13,21 +19,40 @@ export function isLinearImageUrl(url) {
13
19
  return false;
14
20
  }
15
21
  }
22
+ export function isTrelloFileUrl(url) {
23
+ try {
24
+ const parsed = new URL(url);
25
+ return TRELLO_FILE_DOMAINS.some((domain) => parsed.host.includes(domain));
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function isLikelyImageUrl(url) {
32
+ try {
33
+ const parsed = new URL(url);
34
+ return (IMAGE_EXTENSIONS.test(parsed.pathname) ||
35
+ isLinearImageUrl(url) ||
36
+ isTrelloFileUrl(url));
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
16
42
  /**
17
- * Extract Linear image URLs from markdown text (description, comments)
43
+ * Extract image/file URLs from markdown text (description, comments)
18
44
  */
19
- export function extractLinearImageUrls(text) {
45
+ export function extractImageUrls(text) {
20
46
  const urls = [];
21
- // Match markdown image syntax ![alt](url) and plain URLs
22
47
  const patterns = [
23
- /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/g, // ![alt](url)
24
- /(https?:\/\/uploads\.linear\.app\/[^\s)>\]]+)/g, // Plain Linear upload URLs
48
+ /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/g,
49
+ /(https?:\/\/[^\s)>\]]+)/g,
25
50
  ];
26
51
  for (const pattern of patterns) {
27
52
  let match = pattern.exec(text);
28
53
  while (match) {
29
54
  const url = match[1];
30
- if (isLinearImageUrl(url) && !urls.includes(url)) {
55
+ if (isLikelyImageUrl(url) && !urls.includes(url)) {
31
56
  urls.push(url);
32
57
  }
33
58
  match = pattern.exec(text);
@@ -35,6 +60,12 @@ export function extractLinearImageUrls(text) {
35
60
  }
36
61
  return urls;
37
62
  }
63
+ /**
64
+ * Extract Linear image URLs from markdown text (description, comments)
65
+ */
66
+ export function extractLinearImageUrls(text) {
67
+ return extractImageUrls(text).filter((url) => isLinearImageUrl(url));
68
+ }
38
69
  // MIME type to extension mapping
39
70
  const MIME_TO_EXT = {
40
71
  // Images
@@ -90,20 +121,17 @@ function getFileExtension(url, contentType) {
90
121
  // Last resort: unknown binary
91
122
  return { ext: "bin", isImage: false };
92
123
  }
93
- export async function downloadLinearFile(url, issueId, attachmentId, outputDir) {
124
+ async function downloadRemoteFile(url, issueId, attachmentId, outputDir, options) {
94
125
  try {
95
- // Linear files require authentication
96
- const headers = {};
97
- if (process.env.LINEAR_API_KEY) {
98
- headers.Authorization = process.env.LINEAR_API_KEY;
99
- }
100
- const response = await fetch(url, { headers });
126
+ const downloadUrl = options?.transformUrl ? options.transformUrl(url) : url;
127
+ const headers = options?.headers ?? {};
128
+ const response = await fetch(downloadUrl, { headers });
101
129
  if (!response.ok) {
102
130
  console.error(`Failed to download file: ${response.status}`);
103
131
  return undefined;
104
132
  }
105
133
  const contentType = response.headers.get("content-type") || undefined;
106
- const { ext } = getFileExtension(url, contentType);
134
+ const { ext } = getFileExtension(downloadUrl, contentType);
107
135
  const filename = `${issueId}_${attachmentId}.${ext}`;
108
136
  const filepath = path.join(outputDir, filename);
109
137
  const buffer = await response.arrayBuffer();
@@ -115,6 +143,44 @@ export async function downloadLinearFile(url, issueId, attachmentId, outputDir)
115
143
  return undefined;
116
144
  }
117
145
  }
146
+ function getTrelloAuthorizationHeader(credentials) {
147
+ const apiKey = credentials?.apiKey ?? process.env.TRELLO_API_KEY;
148
+ const token = credentials?.token ?? process.env.TRELLO_TOKEN;
149
+ if (!apiKey || !token) {
150
+ return undefined;
151
+ }
152
+ return `OAuth oauth_consumer_key="${apiKey}", oauth_token="${token}"`;
153
+ }
154
+ export async function downloadLinearFile(url, issueId, attachmentId, outputDir) {
155
+ const headers = {};
156
+ if (process.env.LINEAR_API_KEY && isLinearImageUrl(url)) {
157
+ headers.Authorization = process.env.LINEAR_API_KEY;
158
+ }
159
+ return downloadRemoteFile(url, issueId, attachmentId, outputDir, {
160
+ headers,
161
+ });
162
+ }
163
+ export async function downloadTrelloFile(url, issueId, attachmentId, outputDir, credentials) {
164
+ const authorizationHeader = getTrelloAuthorizationHeader(credentials);
165
+ return downloadRemoteFile(url, issueId, attachmentId, outputDir, {
166
+ headers: authorizationHeader
167
+ ? {
168
+ Authorization: authorizationHeader,
169
+ }
170
+ : undefined,
171
+ transformUrl: (rawUrl) => {
172
+ if (!isTrelloFileUrl(rawUrl)) {
173
+ return rawUrl;
174
+ }
175
+ const parsed = new URL(rawUrl);
176
+ parsed.protocol = "https:";
177
+ parsed.host = "api.trello.com";
178
+ parsed.searchParams.delete("key");
179
+ parsed.searchParams.delete("token");
180
+ return parsed.toString();
181
+ },
182
+ });
183
+ }
118
184
  // Alias for backwards compatibility
119
185
  export const downloadLinearImage = downloadLinearFile;
120
186
  export async function clearIssueImages(outputDir, issueId) {
@@ -26,7 +26,13 @@ export async function updateIssueStatus(linearId, targetStatusName, config, team
26
26
  const states = await getWorkflowStates(config, teamKey);
27
27
  const targetState = states.find((s) => s.name === targetStatusName);
28
28
  if (targetState) {
29
- await client.updateIssue(linearId, { stateId: targetState.id });
29
+ const payload = await client.updateIssue(linearId, {
30
+ stateId: targetState.id,
31
+ });
32
+ if (!payload.success) {
33
+ console.error(`Failed to update Linear: mutation returned success=false for ${linearId} → ${targetStatusName}`);
34
+ return false;
35
+ }
30
36
  return true;
31
37
  }
32
38
  return false;
@@ -2,7 +2,7 @@
2
2
  * Helper functions for status transitions
3
3
  * Handles the case where `todo` can be string | string[]
4
4
  */
5
- import type { StatusTransitions } from "../utils.js";
5
+ import type { LocalConfig, StatusTransitions, Task } from "../utils.js";
6
6
  /**
7
7
  * Normalize todo status value to array
8
8
  * Only `todo` supports multiple values for sync filtering
@@ -16,10 +16,21 @@ export declare function isTodoStatus(statusName: string, transitions: StatusTran
16
16
  * Get all statuses to sync (flattens todo array + in_progress)
17
17
  */
18
18
  export declare function getSyncStatuses(transitions: StatusTransitions): string[];
19
+ /**
20
+ * Collect all statuses that should be treated as "in review"
21
+ * This includes the generic testing transition plus any team-specific testing
22
+ * statuses chosen during init/config.
23
+ */
24
+ export declare function getReviewStatuses(transitions: StatusTransitions, localConfig?: LocalConfig): string[];
19
25
  /**
20
26
  * Map remote status to local status using transitions
21
27
  */
22
- export declare function mapRemoteToLocalStatus(remoteStatus: string, transitions: StatusTransitions): "pending" | "in-progress" | "completed" | "in-review" | "blocked";
28
+ export declare function mapRemoteToLocalStatus(remoteStatus: string, transitions: StatusTransitions, localConfig?: LocalConfig): "pending" | "in-progress" | "completed" | "in-review" | "blocked";
29
+ /**
30
+ * Preserve optimistic local progress for active tasks, but let explicit
31
+ * remote review/completed/blocked states override stale local state.
32
+ */
33
+ export declare function resolveLocalStatus(existingLocalStatus: Task["localStatus"] | undefined, remoteStatus: string, transitions: StatusTransitions, localConfig?: LocalConfig): Task["localStatus"];
23
34
  /**
24
35
  * Format todo status for display (handles string | string[])
25
36
  */
@@ -24,17 +24,37 @@ export function isTodoStatus(statusName, transitions) {
24
24
  export function getSyncStatuses(transitions) {
25
25
  return [...normalizeTodoStatuses(transitions.todo), transitions.in_progress];
26
26
  }
27
+ /**
28
+ * Collect all statuses that should be treated as "in review"
29
+ * This includes the generic testing transition plus any team-specific testing
30
+ * statuses chosen during init/config.
31
+ */
32
+ export function getReviewStatuses(transitions, localConfig) {
33
+ const statuses = new Set();
34
+ if (transitions.testing) {
35
+ statuses.add(transitions.testing);
36
+ }
37
+ if (localConfig?.dev_testing_status) {
38
+ statuses.add(localConfig.dev_testing_status);
39
+ }
40
+ for (const team of localConfig?.qa_pm_teams ?? []) {
41
+ if (team.testing_status) {
42
+ statuses.add(team.testing_status);
43
+ }
44
+ }
45
+ return [...statuses];
46
+ }
27
47
  /**
28
48
  * Map remote status to local status using transitions
29
49
  */
30
- export function mapRemoteToLocalStatus(remoteStatus, transitions) {
50
+ export function mapRemoteToLocalStatus(remoteStatus, transitions, localConfig) {
31
51
  if (remoteStatus === transitions.done) {
32
52
  return "completed";
33
53
  }
34
54
  if (remoteStatus === transitions.in_progress) {
35
55
  return "in-progress";
36
56
  }
37
- if (transitions.testing && remoteStatus === transitions.testing) {
57
+ if (getReviewStatuses(transitions, localConfig).includes(remoteStatus)) {
38
58
  return "in-review";
39
59
  }
40
60
  if (transitions.blocked && remoteStatus === transitions.blocked) {
@@ -42,6 +62,22 @@ export function mapRemoteToLocalStatus(remoteStatus, transitions) {
42
62
  }
43
63
  return "pending";
44
64
  }
65
+ /**
66
+ * Preserve optimistic local progress for active tasks, but let explicit
67
+ * remote review/completed/blocked states override stale local state.
68
+ */
69
+ export function resolveLocalStatus(existingLocalStatus, remoteStatus, transitions, localConfig) {
70
+ const remoteLocalStatus = mapRemoteToLocalStatus(remoteStatus, transitions, localConfig);
71
+ if (!existingLocalStatus) {
72
+ return remoteLocalStatus;
73
+ }
74
+ if (remoteLocalStatus === "in-review" ||
75
+ remoteLocalStatus === "completed" ||
76
+ remoteLocalStatus === "blocked") {
77
+ return remoteLocalStatus;
78
+ }
79
+ return existingLocalStatus;
80
+ }
45
81
  /**
46
82
  * Format todo status for display (handles string | string[])
47
83
  */
@@ -62,7 +62,7 @@ export async function fetchIssueDetail(issueId, options = {}) {
62
62
  * Returns the updated task or null if issue not found
63
63
  */
64
64
  export async function syncSingleIssue(issueId, options) {
65
- const { config, localConfig: _localConfig, preserveLocalStatus = true, } = options;
65
+ const { config, localConfig, preserveLocalStatus = true, } = options;
66
66
  const client = options.client ?? getLinearClient();
67
67
  // Fetch issue details using shared function
68
68
  const task = await fetchIssueDetail(issueId, { client });
@@ -81,7 +81,7 @@ export async function syncSingleIssue(issueId, options) {
81
81
  // Map remote status to local status if not preserving
82
82
  if (!preserveLocalStatus) {
83
83
  const transitions = getStatusTransitions(config);
84
- task.localStatus = mapRemoteToLocalStatus(task.status, transitions);
84
+ task.localStatus = mapRemoteToLocalStatus(task.status, transitions, localConfig);
85
85
  }
86
86
  // Update cycle data
87
87
  if (existingData) {
@@ -1,7 +1,31 @@
1
1
  import { createAdapter } from "./lib/adapters/index.js";
2
- import { clearAllOutput, clearIssueImages, downloadLinearImage, ensureOutputDir, extractLinearImageUrls, isLinearImageUrl, } from "./lib/files.js";
3
- import { getSyncStatuses } from "./lib/status-helpers.js";
2
+ import { clearAllOutput, clearIssueImages, downloadLinearImage, downloadTrelloFile, ensureOutputDir, extractImageUrls, isLinearImageUrl, } from "./lib/files.js";
3
+ import { getReviewStatuses, getSyncStatuses, resolveLocalStatus, } from "./lib/status-helpers.js";
4
4
  import { getLinearClient, getPaths, getPrioritySortIndex, getSourceType, getTeamId, loadConfig, loadCycleData, loadLocalConfig, saveConfig, saveCycleData, } from "./utils.js";
5
+ async function downloadEmbeddedImages(texts, issueId, attachments, outputDir, downloadFile, titlePrefix, sourceType, filterUrl) {
6
+ let imageIndex = 0;
7
+ for (const text of texts) {
8
+ if (!text)
9
+ continue;
10
+ for (const url of extractImageUrls(text)) {
11
+ if (filterUrl && !filterUrl(url))
12
+ continue;
13
+ if (attachments.some((a) => a.url === url))
14
+ continue;
15
+ const attachmentId = `${titlePrefix.toLowerCase()}_${imageIndex++}`;
16
+ const localPath = await downloadFile(url, issueId, attachmentId, outputDir);
17
+ if (localPath) {
18
+ attachments.push({
19
+ id: attachmentId,
20
+ title: `${titlePrefix} Image`,
21
+ url,
22
+ sourceType,
23
+ localPath,
24
+ });
25
+ }
26
+ }
27
+ }
28
+ }
5
29
  async function sync() {
6
30
  const args = process.argv.slice(2);
7
31
  // Handle help flag
@@ -148,7 +172,12 @@ Examples:
148
172
  }
149
173
  if (targetStateId) {
150
174
  try {
151
- await client.updateIssue(task.linearId, { stateId: targetStateId });
175
+ const payload = await client.updateIssue(task.linearId, {
176
+ stateId: targetStateId,
177
+ });
178
+ if (!payload.success) {
179
+ throw new Error("Linear mutation returned success=false");
180
+ }
152
181
  const targetName = targetStateId === inProgressStateId
153
182
  ? statusTransitions.in_progress
154
183
  : statusTransitions.testing;
@@ -257,29 +286,6 @@ Examples:
257
286
  }
258
287
  attachments.push(attachment);
259
288
  }
260
- // Extract and download images from description
261
- if (issue.description) {
262
- const descriptionImageUrls = extractLinearImageUrls(issue.description);
263
- for (const url of descriptionImageUrls) {
264
- // Generate a short ID from URL (last segment of path)
265
- const urlPath = new URL(url).pathname;
266
- const segments = urlPath.split("/").filter(Boolean);
267
- const imageId = segments[segments.length - 1] || `desc_${Date.now()}`;
268
- // Skip if already in attachments
269
- if (attachments.some((a) => a.url === url))
270
- continue;
271
- const localPath = await downloadLinearImage(url, issue.identifier, imageId, outputPath);
272
- if (localPath) {
273
- attachments.push({
274
- id: imageId,
275
- title: `Description Image`,
276
- url: url,
277
- sourceType: "description",
278
- localPath: localPath,
279
- });
280
- }
281
- }
282
- }
283
289
  // Build comments list
284
290
  const comments = await Promise.all(commentsData.nodes.map(async (c) => {
285
291
  const user = await c.user;
@@ -290,42 +296,41 @@ Examples:
290
296
  user: user?.displayName ?? user?.email,
291
297
  };
292
298
  }));
299
+ await downloadEmbeddedImages([
300
+ issue.description ?? undefined,
301
+ ...comments
302
+ .map((comment) => comment.body)
303
+ .filter((body) => Boolean(body)),
304
+ ], issue.identifier, attachments, outputPath, downloadLinearImage, "Embedded", "description", isLinearImageUrl);
293
305
  let localStatus = "pending";
294
306
  // Preserve local status & sync completed tasks to Linear
295
307
  if (existingTasksMap.has(issue.identifier)) {
296
308
  const existing = existingTasksMap.get(issue.identifier);
297
- localStatus = existing?.localStatus ?? "pending";
309
+ localStatus = resolveLocalStatus(existing?.localStatus, state?.name ?? "", statusTransitions, localConfig);
298
310
  if (localStatus === "completed" && state && testingStateId) {
299
311
  // Skip if already in terminal states (done, testing, or cancelled type)
300
- const terminalStates = [statusTransitions.done];
301
- if (statusTransitions.testing)
302
- terminalStates.push(statusTransitions.testing);
312
+ const terminalStates = [
313
+ statusTransitions.done,
314
+ ...getReviewStatuses(statusTransitions, localConfig),
315
+ ];
303
316
  const isTerminal = terminalStates.includes(state.name) || state.type === "cancelled";
304
317
  if (!isTerminal) {
305
318
  console.log(`Updating ${issue.identifier} to ${statusTransitions.testing} in Linear...`);
306
- await client.updateIssue(issue.id, { stateId: testingStateId });
319
+ const payload = await client.updateIssue(issue.id, {
320
+ stateId: testingStateId,
321
+ });
322
+ if (!payload.success) {
323
+ throw new Error("Linear mutation returned success=false");
324
+ }
307
325
  updatedCount++;
308
326
  }
309
327
  }
310
328
  }
311
329
  else if (state) {
312
- // New task: infer localStatus from remote status
313
- if (state.name === statusTransitions.in_progress) {
314
- localStatus = "in-progress";
315
- }
316
- else if (statusTransitions.testing &&
317
- state.name === statusTransitions.testing) {
318
- localStatus = "in-review";
319
- }
320
- else if (state.name === statusTransitions.done ||
321
- state.type === "completed") {
330
+ localStatus = resolveLocalStatus(undefined, state.name, statusTransitions, localConfig);
331
+ if (state.type === "completed") {
322
332
  localStatus = "completed";
323
333
  }
324
- else if (statusTransitions.blocked &&
325
- state.name === statusTransitions.blocked) {
326
- localStatus = "blocked";
327
- }
328
- // Default "pending" for todo or other states
329
334
  }
330
335
  const task = {
331
336
  id: issue.identifier,
@@ -384,6 +389,11 @@ Examples:
384
389
  }
385
390
  async function syncTrello(config, localConfig, options) {
386
391
  const { shouldUpdate, syncAll, singleIssueId, outputPath } = options;
392
+ const trelloCredentials = {
393
+ apiKey: config.source?.trello?.apiKey ?? process.env.TRELLO_API_KEY,
394
+ token: config.source?.trello?.token ?? process.env.TRELLO_TOKEN,
395
+ };
396
+ const downloadTrelloAttachment = (url, issueId, attachmentId, outputDir) => downloadTrelloFile(url, issueId, attachmentId, outputDir, trelloCredentials);
387
397
  // Create adapter
388
398
  const adapter = createAdapter(config);
389
399
  // Validate connection
@@ -501,29 +511,39 @@ async function syncTrello(config, localConfig, options) {
501
511
  }
502
512
  // Fetch full issue details (comments, attachments) per card
503
513
  const fullIssue = await adapter.getIssue(issue.sourceId);
514
+ const comments = fullIssue?.comments?.map((c) => ({
515
+ id: c.id,
516
+ body: c.body,
517
+ createdAt: c.createdAt,
518
+ user: c.user,
519
+ })) ?? [];
520
+ const attachments = fullIssue?.attachments?.map((a) => ({
521
+ id: a.id,
522
+ title: a.title,
523
+ url: a.url,
524
+ sourceType: a.sourceType,
525
+ })) ?? [];
526
+ await clearIssueImages(outputPath, issue.id);
527
+ for (const attachment of attachments) {
528
+ const localPath = await downloadTrelloAttachment(attachment.url, issue.id, attachment.id, outputPath);
529
+ if (localPath) {
530
+ attachment.localPath = localPath;
531
+ }
532
+ }
533
+ await downloadEmbeddedImages([
534
+ fullIssue?.description ?? issue.description,
535
+ ...comments
536
+ .map((comment) => comment.body)
537
+ .filter((body) => Boolean(body)),
538
+ ], issue.id, attachments, outputPath, downloadTrelloAttachment, "Trello", "trello");
504
539
  // Preserve local status or infer from remote status
505
540
  let localStatus = "pending";
506
541
  if (existingTasksMap.has(issue.id)) {
507
542
  const existing = existingTasksMap.get(issue.id);
508
- localStatus = existing?.localStatus ?? "pending";
543
+ localStatus = resolveLocalStatus(existing?.localStatus, issue.status, statusTransitions, localConfig);
509
544
  }
510
545
  else {
511
- // New task: infer localStatus from remote status
512
- if (issue.status === statusTransitions.in_progress) {
513
- localStatus = "in-progress";
514
- }
515
- else if (statusTransitions.testing &&
516
- issue.status === statusTransitions.testing) {
517
- localStatus = "in-review";
518
- }
519
- else if (issue.status === statusTransitions.done) {
520
- localStatus = "completed";
521
- }
522
- else if (statusTransitions.blocked &&
523
- issue.status === statusTransitions.blocked) {
524
- localStatus = "blocked";
525
- }
526
- // Default "pending" for todo or other states
546
+ localStatus = resolveLocalStatus(undefined, issue.status, statusTransitions, localConfig);
527
547
  }
528
548
  const task = {
529
549
  id: issue.id,
@@ -539,18 +559,8 @@ async function syncTrello(config, localConfig, options) {
539
559
  description: issue.description,
540
560
  parentIssueId: issue.parentIssueId,
541
561
  url: issue.url,
542
- attachments: fullIssue?.attachments?.map((a) => ({
543
- id: a.id,
544
- title: a.title,
545
- url: a.url,
546
- sourceType: a.sourceType,
547
- })),
548
- comments: fullIssue?.comments?.map((c) => ({
549
- id: c.id,
550
- body: c.body,
551
- createdAt: c.createdAt,
552
- user: c.user,
553
- })),
562
+ attachments: attachments.length > 0 ? attachments : undefined,
563
+ comments: comments.length > 0 ? comments : undefined,
554
564
  };
555
565
  tasks.push(task);
556
566
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "team-toon-tack",
3
- "version": "3.2.10",
3
+ "version": "3.2.12",
4
4
  "description": "Linear & Trello task sync & management CLI with TOON format",
5
5
  "type": "module",
6
6
  "bin": {