team-toon-tack 3.2.10 → 3.2.11
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.
- package/dist/scripts/done-job.js +13 -2
- package/dist/scripts/lib/adapters/linear-adapter.js +9 -1
- package/dist/scripts/lib/done/linear-handler.js +35 -2
- package/dist/scripts/lib/done/trello-handler.js +6 -0
- package/dist/scripts/lib/files.d.ts +6 -0
- package/dist/scripts/lib/files.js +68 -14
- package/dist/scripts/lib/linear.js +7 -1
- package/dist/scripts/lib/status-helpers.d.ts +13 -2
- package/dist/scripts/lib/status-helpers.js +38 -2
- package/dist/scripts/lib/sync.js +2 -2
- package/dist/scripts/sync.js +80 -75
- package/package.json +1 -1
package/dist/scripts/done-job.js
CHANGED
|
@@ -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, {
|
|
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 {
|
|
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,15 @@
|
|
|
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[];
|
|
6
11
|
export declare function downloadLinearFile(url: string, issueId: string, attachmentId: string, outputDir: string): Promise<string | undefined>;
|
|
12
|
+
export declare function downloadTrelloFile(url: string, issueId: string, attachmentId: string, outputDir: string): Promise<string | undefined>;
|
|
7
13
|
export declare const downloadLinearImage: typeof downloadLinearFile;
|
|
8
14
|
export declare function clearIssueImages(outputDir: string, issueId: string): Promise<void>;
|
|
9
15
|
export declare function ensureOutputDir(outputDir: string): Promise<void>;
|
|
@@ -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
|
|
43
|
+
* Extract image/file URLs from markdown text (description, comments)
|
|
18
44
|
*/
|
|
19
|
-
export function
|
|
45
|
+
export function extractImageUrls(text) {
|
|
20
46
|
const urls = [];
|
|
21
|
-
// Match markdown image syntax  and plain URLs
|
|
22
47
|
const patterns = [
|
|
23
|
-
/!\[[^\]]*\]\((https?:\/\/[^)]+)\)/g,
|
|
24
|
-
/(https?:\/\/
|
|
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 (
|
|
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
|
-
|
|
124
|
+
async function downloadRemoteFile(url, issueId, attachmentId, outputDir, options) {
|
|
94
125
|
try {
|
|
95
|
-
|
|
96
|
-
const headers = {};
|
|
97
|
-
|
|
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(
|
|
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,32 @@ export async function downloadLinearFile(url, issueId, attachmentId, outputDir)
|
|
|
115
143
|
return undefined;
|
|
116
144
|
}
|
|
117
145
|
}
|
|
146
|
+
export async function downloadLinearFile(url, issueId, attachmentId, outputDir) {
|
|
147
|
+
const headers = {};
|
|
148
|
+
if (process.env.LINEAR_API_KEY && isLinearImageUrl(url)) {
|
|
149
|
+
headers.Authorization = process.env.LINEAR_API_KEY;
|
|
150
|
+
}
|
|
151
|
+
return downloadRemoteFile(url, issueId, attachmentId, outputDir, {
|
|
152
|
+
headers,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
export async function downloadTrelloFile(url, issueId, attachmentId, outputDir) {
|
|
156
|
+
return downloadRemoteFile(url, issueId, attachmentId, outputDir, {
|
|
157
|
+
transformUrl: (rawUrl) => {
|
|
158
|
+
if (!isTrelloFileUrl(rawUrl)) {
|
|
159
|
+
return rawUrl;
|
|
160
|
+
}
|
|
161
|
+
const parsed = new URL(rawUrl);
|
|
162
|
+
if (process.env.TRELLO_API_KEY && !parsed.searchParams.has("key")) {
|
|
163
|
+
parsed.searchParams.set("key", process.env.TRELLO_API_KEY);
|
|
164
|
+
}
|
|
165
|
+
if (process.env.TRELLO_TOKEN && !parsed.searchParams.has("token")) {
|
|
166
|
+
parsed.searchParams.set("token", process.env.TRELLO_TOKEN);
|
|
167
|
+
}
|
|
168
|
+
return parsed.toString();
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
118
172
|
// Alias for backwards compatibility
|
|
119
173
|
export const downloadLinearImage = downloadLinearFile;
|
|
120
174
|
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, {
|
|
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.
|
|
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
|
*/
|
package/dist/scripts/lib/sync.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/scripts/sync.js
CHANGED
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import { createAdapter } from "./lib/adapters/index.js";
|
|
2
|
-
import { clearAllOutput, clearIssueImages, downloadLinearImage, ensureOutputDir,
|
|
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, {
|
|
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 ?? "
|
|
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 = [
|
|
301
|
-
|
|
302
|
-
|
|
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, {
|
|
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
|
-
|
|
313
|
-
if (state.
|
|
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,
|
|
@@ -501,29 +506,39 @@ async function syncTrello(config, localConfig, options) {
|
|
|
501
506
|
}
|
|
502
507
|
// Fetch full issue details (comments, attachments) per card
|
|
503
508
|
const fullIssue = await adapter.getIssue(issue.sourceId);
|
|
509
|
+
const comments = fullIssue?.comments?.map((c) => ({
|
|
510
|
+
id: c.id,
|
|
511
|
+
body: c.body,
|
|
512
|
+
createdAt: c.createdAt,
|
|
513
|
+
user: c.user,
|
|
514
|
+
})) ?? [];
|
|
515
|
+
const attachments = fullIssue?.attachments?.map((a) => ({
|
|
516
|
+
id: a.id,
|
|
517
|
+
title: a.title,
|
|
518
|
+
url: a.url,
|
|
519
|
+
sourceType: a.sourceType,
|
|
520
|
+
})) ?? [];
|
|
521
|
+
await clearIssueImages(outputPath, issue.id);
|
|
522
|
+
for (const attachment of attachments) {
|
|
523
|
+
const localPath = await downloadTrelloFile(attachment.url, issue.id, attachment.id, outputPath);
|
|
524
|
+
if (localPath) {
|
|
525
|
+
attachment.localPath = localPath;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
await downloadEmbeddedImages([
|
|
529
|
+
fullIssue?.description ?? issue.description,
|
|
530
|
+
...comments
|
|
531
|
+
.map((comment) => comment.body)
|
|
532
|
+
.filter((body) => Boolean(body)),
|
|
533
|
+
], issue.id, attachments, outputPath, downloadTrelloFile, "Trello", "trello");
|
|
504
534
|
// Preserve local status or infer from remote status
|
|
505
535
|
let localStatus = "pending";
|
|
506
536
|
if (existingTasksMap.has(issue.id)) {
|
|
507
537
|
const existing = existingTasksMap.get(issue.id);
|
|
508
|
-
localStatus = existing?.localStatus
|
|
538
|
+
localStatus = resolveLocalStatus(existing?.localStatus, issue.status, statusTransitions, localConfig);
|
|
509
539
|
}
|
|
510
540
|
else {
|
|
511
|
-
|
|
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
|
|
541
|
+
localStatus = resolveLocalStatus(undefined, issue.status, statusTransitions, localConfig);
|
|
527
542
|
}
|
|
528
543
|
const task = {
|
|
529
544
|
id: issue.id,
|
|
@@ -539,18 +554,8 @@ async function syncTrello(config, localConfig, options) {
|
|
|
539
554
|
description: issue.description,
|
|
540
555
|
parentIssueId: issue.parentIssueId,
|
|
541
556
|
url: issue.url,
|
|
542
|
-
attachments:
|
|
543
|
-
|
|
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
|
-
})),
|
|
557
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
558
|
+
comments: comments.length > 0 ? comments : undefined,
|
|
554
559
|
};
|
|
555
560
|
tasks.push(task);
|
|
556
561
|
}
|