team-toon-tack 1.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.
- package/README.md +187 -0
- package/bin/cli.ts +119 -0
- package/package.json +51 -0
- package/scripts/done-job.ts +242 -0
- package/scripts/init.ts +360 -0
- package/scripts/sync.ts +172 -0
- package/scripts/utils.ts +208 -0
- package/scripts/work-on.ts +145 -0
- package/templates/config.example.toon +82 -0
- package/templates/local.example.toon +13 -0
package/scripts/init.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import prompts from 'prompts';
|
|
5
|
+
import { encode, decode } from '@toon-format/toon';
|
|
6
|
+
import { getLinearClient, getPaths, fileExists, Config, LocalConfig } from './utils';
|
|
7
|
+
|
|
8
|
+
interface InitOptions {
|
|
9
|
+
apiKey?: string;
|
|
10
|
+
user?: string;
|
|
11
|
+
team?: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
force?: boolean;
|
|
14
|
+
interactive?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseArgs(args: string[]): InitOptions {
|
|
18
|
+
const options: InitOptions = { interactive: true };
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const arg = args[i];
|
|
22
|
+
switch (arg) {
|
|
23
|
+
case '--api-key':
|
|
24
|
+
case '-k':
|
|
25
|
+
options.apiKey = args[++i];
|
|
26
|
+
break;
|
|
27
|
+
case '--user':
|
|
28
|
+
case '-u':
|
|
29
|
+
options.user = args[++i];
|
|
30
|
+
break;
|
|
31
|
+
case '--team':
|
|
32
|
+
case '-t':
|
|
33
|
+
options.team = args[++i];
|
|
34
|
+
break;
|
|
35
|
+
case '--label':
|
|
36
|
+
case '-l':
|
|
37
|
+
options.label = args[++i];
|
|
38
|
+
break;
|
|
39
|
+
case '--force':
|
|
40
|
+
case '-f':
|
|
41
|
+
options.force = true;
|
|
42
|
+
break;
|
|
43
|
+
case '--yes':
|
|
44
|
+
case '-y':
|
|
45
|
+
options.interactive = false;
|
|
46
|
+
break;
|
|
47
|
+
case '--help':
|
|
48
|
+
case '-h':
|
|
49
|
+
printHelp();
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return options;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function printHelp() {
|
|
58
|
+
console.log(`
|
|
59
|
+
linear-toon init - Initialize configuration files
|
|
60
|
+
|
|
61
|
+
USAGE:
|
|
62
|
+
bun run init [OPTIONS]
|
|
63
|
+
|
|
64
|
+
OPTIONS:
|
|
65
|
+
-k, --api-key <key> Linear API key (or set LINEAR_API_KEY env)
|
|
66
|
+
-u, --user <email> Your email address in Linear
|
|
67
|
+
-t, --team <name> Team name to sync (optional, fetches from Linear)
|
|
68
|
+
-l, --label <name> Default label filter (e.g., Frontend, Backend)
|
|
69
|
+
-f, --force Overwrite existing config files
|
|
70
|
+
-y, --yes Non-interactive mode (use defaults/provided args)
|
|
71
|
+
-h, --help Show this help message
|
|
72
|
+
|
|
73
|
+
EXAMPLES:
|
|
74
|
+
bun run init
|
|
75
|
+
bun run init --user alice@example.com --label Frontend
|
|
76
|
+
bun run init -k lin_api_xxx -y
|
|
77
|
+
`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function init() {
|
|
81
|
+
const args = process.argv.slice(2);
|
|
82
|
+
const options = parseArgs(args);
|
|
83
|
+
const paths = getPaths();
|
|
84
|
+
|
|
85
|
+
console.log('š Linear-TOON Initialization\n');
|
|
86
|
+
|
|
87
|
+
// Check existing files
|
|
88
|
+
const configExists = await fileExists(paths.configPath);
|
|
89
|
+
const localExists = await fileExists(paths.localPath);
|
|
90
|
+
|
|
91
|
+
if ((configExists || localExists) && !options.force) {
|
|
92
|
+
console.log('Existing configuration found:');
|
|
93
|
+
if (configExists) console.log(` ā ${paths.configPath}`);
|
|
94
|
+
if (localExists) console.log(` ā ${paths.localPath}`);
|
|
95
|
+
|
|
96
|
+
if (options.interactive) {
|
|
97
|
+
const { proceed } = await prompts({
|
|
98
|
+
type: 'confirm',
|
|
99
|
+
name: 'proceed',
|
|
100
|
+
message: 'Update existing configuration?',
|
|
101
|
+
initial: true
|
|
102
|
+
});
|
|
103
|
+
if (!proceed) {
|
|
104
|
+
console.log('Cancelled.');
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
console.log('Use --force to overwrite existing files.');
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Get API key
|
|
114
|
+
let apiKey = options.apiKey || process.env.LINEAR_API_KEY;
|
|
115
|
+
if (!apiKey && options.interactive) {
|
|
116
|
+
const response = await prompts({
|
|
117
|
+
type: 'password',
|
|
118
|
+
name: 'apiKey',
|
|
119
|
+
message: 'Enter your Linear API key:',
|
|
120
|
+
validate: v => v.startsWith('lin_api_') ? true : 'API key should start with "lin_api_"'
|
|
121
|
+
});
|
|
122
|
+
apiKey = response.apiKey;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!apiKey) {
|
|
126
|
+
console.error('Error: LINEAR_API_KEY is required.');
|
|
127
|
+
console.error('Get your API key from: https://linear.app/settings/api');
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Create Linear client
|
|
132
|
+
const client = getLinearClient();
|
|
133
|
+
|
|
134
|
+
console.log('\nš” Fetching data from Linear...');
|
|
135
|
+
|
|
136
|
+
// Fetch teams
|
|
137
|
+
const teamsData = await client.teams();
|
|
138
|
+
const teams = teamsData.nodes;
|
|
139
|
+
|
|
140
|
+
if (teams.length === 0) {
|
|
141
|
+
console.error('Error: No teams found in your Linear workspace.');
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Select team
|
|
146
|
+
let selectedTeam = teams[0];
|
|
147
|
+
if (options.team) {
|
|
148
|
+
const found = teams.find(t => t.name.toLowerCase() === options.team!.toLowerCase());
|
|
149
|
+
if (found) selectedTeam = found;
|
|
150
|
+
} else if (options.interactive && teams.length > 1) {
|
|
151
|
+
const response = await prompts({
|
|
152
|
+
type: 'select',
|
|
153
|
+
name: 'teamId',
|
|
154
|
+
message: 'Select your primary team:',
|
|
155
|
+
choices: teams.map(t => ({ title: t.name, value: t.id }))
|
|
156
|
+
});
|
|
157
|
+
selectedTeam = teams.find(t => t.id === response.teamId) || teams[0];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(` Team: ${selectedTeam.name}`);
|
|
161
|
+
|
|
162
|
+
// Fetch team members
|
|
163
|
+
const members = await selectedTeam.members();
|
|
164
|
+
const users = members.nodes;
|
|
165
|
+
console.log(` Users: ${users.length}`);
|
|
166
|
+
|
|
167
|
+
// Fetch labels
|
|
168
|
+
const labelsData = await client.issueLabels({
|
|
169
|
+
filter: { team: { id: { eq: selectedTeam.id } } }
|
|
170
|
+
});
|
|
171
|
+
const labels = labelsData.nodes;
|
|
172
|
+
console.log(` Labels: ${labels.length}`);
|
|
173
|
+
|
|
174
|
+
// Fetch workflow states
|
|
175
|
+
const statesData = await client.workflowStates({
|
|
176
|
+
filter: { team: { id: { eq: selectedTeam.id } } }
|
|
177
|
+
});
|
|
178
|
+
const states = statesData.nodes;
|
|
179
|
+
|
|
180
|
+
// Fetch current cycle
|
|
181
|
+
const cyclesData = await selectedTeam.cycles({
|
|
182
|
+
filter: { isActive: { eq: true } },
|
|
183
|
+
first: 1
|
|
184
|
+
});
|
|
185
|
+
const currentCycle = cyclesData.nodes[0];
|
|
186
|
+
|
|
187
|
+
// Select current user
|
|
188
|
+
let currentUser = users[0];
|
|
189
|
+
if (options.user) {
|
|
190
|
+
const found = users.find(u =>
|
|
191
|
+
u.email?.toLowerCase() === options.user!.toLowerCase() ||
|
|
192
|
+
u.displayName?.toLowerCase() === options.user!.toLowerCase()
|
|
193
|
+
);
|
|
194
|
+
if (found) currentUser = found;
|
|
195
|
+
} else if (options.interactive) {
|
|
196
|
+
const response = await prompts({
|
|
197
|
+
type: 'select',
|
|
198
|
+
name: 'userId',
|
|
199
|
+
message: 'Select yourself:',
|
|
200
|
+
choices: users.map(u => ({
|
|
201
|
+
title: `${u.displayName || u.name} (${u.email})`,
|
|
202
|
+
value: u.id
|
|
203
|
+
}))
|
|
204
|
+
});
|
|
205
|
+
currentUser = users.find(u => u.id === response.userId) || users[0];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Select default label
|
|
209
|
+
let defaultLabel = labels[0]?.name || 'Frontend';
|
|
210
|
+
if (options.label) {
|
|
211
|
+
defaultLabel = options.label;
|
|
212
|
+
} else if (options.interactive && labels.length > 0) {
|
|
213
|
+
const response = await prompts({
|
|
214
|
+
type: 'select',
|
|
215
|
+
name: 'label',
|
|
216
|
+
message: 'Select default label filter:',
|
|
217
|
+
choices: labels.map(l => ({ title: l.name, value: l.name }))
|
|
218
|
+
});
|
|
219
|
+
defaultLabel = response.label || defaultLabel;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Build config
|
|
223
|
+
const teamsConfig: Record<string, { id: string; name: string; icon?: string }> = {};
|
|
224
|
+
for (const team of teams) {
|
|
225
|
+
const key = team.name.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
226
|
+
teamsConfig[key] = {
|
|
227
|
+
id: team.id,
|
|
228
|
+
name: team.name,
|
|
229
|
+
icon: team.icon || undefined
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const usersConfig: Record<string, { id: string; email: string; displayName: string; role?: string }> = {};
|
|
234
|
+
for (const user of users) {
|
|
235
|
+
const key = (user.displayName || user.name || user.email?.split('@')[0] || 'user')
|
|
236
|
+
.toLowerCase()
|
|
237
|
+
.replace(/[^a-z0-9]/g, '_');
|
|
238
|
+
usersConfig[key] = {
|
|
239
|
+
id: user.id,
|
|
240
|
+
email: user.email || '',
|
|
241
|
+
displayName: user.displayName || user.name || ''
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const labelsConfig: Record<string, { id: string; name: string; color?: string }> = {};
|
|
246
|
+
for (const label of labels) {
|
|
247
|
+
const key = label.name.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
248
|
+
labelsConfig[key] = {
|
|
249
|
+
id: label.id,
|
|
250
|
+
name: label.name,
|
|
251
|
+
color: label.color || undefined
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const statusesConfig: Record<string, { name: string; type: string }> = {};
|
|
256
|
+
for (const state of states) {
|
|
257
|
+
const key = state.name.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
258
|
+
statusesConfig[key] = {
|
|
259
|
+
name: state.name,
|
|
260
|
+
type: state.type
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const config: Config = {
|
|
265
|
+
teams: teamsConfig,
|
|
266
|
+
users: usersConfig,
|
|
267
|
+
labels: labelsConfig,
|
|
268
|
+
priorities: {
|
|
269
|
+
urgent: { value: 1, name: 'Urgent' },
|
|
270
|
+
high: { value: 2, name: 'High' },
|
|
271
|
+
medium: { value: 3, name: 'Medium' },
|
|
272
|
+
low: { value: 4, name: 'Low' }
|
|
273
|
+
},
|
|
274
|
+
statuses: statusesConfig,
|
|
275
|
+
status_transitions: {
|
|
276
|
+
start_work: 'In Progress',
|
|
277
|
+
complete: 'Done',
|
|
278
|
+
need_review: 'In Review'
|
|
279
|
+
},
|
|
280
|
+
current_cycle: currentCycle ? {
|
|
281
|
+
id: currentCycle.id,
|
|
282
|
+
name: currentCycle.name || 'Cycle',
|
|
283
|
+
start_date: currentCycle.startsAt?.toISOString().split('T')[0] || '',
|
|
284
|
+
end_date: currentCycle.endsAt?.toISOString().split('T')[0] || ''
|
|
285
|
+
} : {
|
|
286
|
+
id: '',
|
|
287
|
+
name: 'No active cycle',
|
|
288
|
+
start_date: '',
|
|
289
|
+
end_date: ''
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Find current user key
|
|
294
|
+
const currentUserKey = Object.entries(usersConfig).find(
|
|
295
|
+
([_, u]) => u.id === currentUser.id
|
|
296
|
+
)?.[0] || 'user';
|
|
297
|
+
|
|
298
|
+
const localConfig: LocalConfig = {
|
|
299
|
+
current_user: currentUserKey,
|
|
300
|
+
label: defaultLabel
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// Write config files
|
|
304
|
+
console.log('\nš Writing configuration files...');
|
|
305
|
+
|
|
306
|
+
// Merge with existing config if exists
|
|
307
|
+
if (configExists && !options.force) {
|
|
308
|
+
try {
|
|
309
|
+
const existingContent = await fs.readFile(paths.configPath, 'utf-8');
|
|
310
|
+
const existingConfig = decode(existingContent) as unknown as Config;
|
|
311
|
+
|
|
312
|
+
// Merge: new data takes precedence but preserve existing custom fields
|
|
313
|
+
config.status_transitions = {
|
|
314
|
+
...existingConfig.status_transitions,
|
|
315
|
+
...config.status_transitions
|
|
316
|
+
};
|
|
317
|
+
} catch {
|
|
318
|
+
// Ignore merge errors
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
await fs.writeFile(paths.configPath, encode(config), 'utf-8');
|
|
323
|
+
console.log(` ā ${paths.configPath}`);
|
|
324
|
+
|
|
325
|
+
// Merge local config
|
|
326
|
+
if (localExists && !options.force) {
|
|
327
|
+
try {
|
|
328
|
+
const existingContent = await fs.readFile(paths.localPath, 'utf-8');
|
|
329
|
+
const existingLocal = decode(existingContent) as unknown as LocalConfig;
|
|
330
|
+
|
|
331
|
+
// Preserve existing values
|
|
332
|
+
if (existingLocal.current_user) localConfig.current_user = existingLocal.current_user;
|
|
333
|
+
if (existingLocal.label) localConfig.label = existingLocal.label;
|
|
334
|
+
if (existingLocal.exclude_assignees) localConfig.exclude_assignees = existingLocal.exclude_assignees;
|
|
335
|
+
} catch {
|
|
336
|
+
// Ignore merge errors
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await fs.writeFile(paths.localPath, encode(localConfig), 'utf-8');
|
|
341
|
+
console.log(` ā ${paths.localPath}`);
|
|
342
|
+
|
|
343
|
+
// Summary
|
|
344
|
+
console.log('\nā
Initialization complete!\n');
|
|
345
|
+
console.log('Configuration summary:');
|
|
346
|
+
console.log(` Team: ${selectedTeam.name}`);
|
|
347
|
+
console.log(` User: ${currentUser.displayName || currentUser.name} (${currentUser.email})`);
|
|
348
|
+
console.log(` Label: ${defaultLabel}`);
|
|
349
|
+
if (currentCycle) {
|
|
350
|
+
console.log(` Cycle: ${currentCycle.name}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
console.log('\nNext steps:');
|
|
354
|
+
console.log(' 1. Set LINEAR_API_KEY in your shell profile:');
|
|
355
|
+
console.log(` export LINEAR_API_KEY="${apiKey}"`);
|
|
356
|
+
console.log(' 2. Run sync: bun run sync');
|
|
357
|
+
console.log(' 3. Start working: bun run work-on');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
init().catch(console.error);
|
package/scripts/sync.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { getLinearClient, loadConfig, loadLocalConfig, saveConfig, loadCycleData, saveCycleData, getTeamId, CycleData, Task, Attachment, Comment } from './utils';
|
|
2
|
+
|
|
3
|
+
async function sync() {
|
|
4
|
+
const config = await loadConfig();
|
|
5
|
+
const localConfig = await loadLocalConfig();
|
|
6
|
+
const client = getLinearClient();
|
|
7
|
+
const teamId = getTeamId(config);
|
|
8
|
+
|
|
9
|
+
// Build excluded emails from local config
|
|
10
|
+
const excludedEmails = new Set(
|
|
11
|
+
(localConfig.exclude_assignees ?? [])
|
|
12
|
+
.map(key => config.users[key]?.email)
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
// Phase 1: Fetch and update latest active cycle
|
|
17
|
+
console.log('Fetching latest cycle...');
|
|
18
|
+
const team = await client.team(teamId);
|
|
19
|
+
const cycles = await team.cycles({
|
|
20
|
+
filter: { isActive: { eq: true } },
|
|
21
|
+
first: 1
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (cycles.nodes.length === 0) {
|
|
25
|
+
console.error('No active cycle found.');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const activeCycle = cycles.nodes[0];
|
|
30
|
+
const cycleChanged = config.current_cycle.id !== activeCycle.id;
|
|
31
|
+
|
|
32
|
+
if (cycleChanged) {
|
|
33
|
+
console.log(`Cycle changed: ${config.current_cycle.name} ā ${activeCycle.name}`);
|
|
34
|
+
config.current_cycle = {
|
|
35
|
+
id: activeCycle.id,
|
|
36
|
+
name: activeCycle.name ?? `Cycle`,
|
|
37
|
+
start_date: activeCycle.startsAt?.toISOString().split('T')[0] ?? '',
|
|
38
|
+
end_date: activeCycle.endsAt?.toISOString().split('T')[0] ?? ''
|
|
39
|
+
};
|
|
40
|
+
await saveConfig(config);
|
|
41
|
+
console.log('Config updated with new cycle.');
|
|
42
|
+
} else {
|
|
43
|
+
console.log(`Current cycle: ${config.current_cycle.name}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cycleId = config.current_cycle.id;
|
|
47
|
+
|
|
48
|
+
// Phase 2: Fetch workflow states
|
|
49
|
+
const workflowStates = await client.workflowStates({
|
|
50
|
+
filter: { team: { id: { eq: teamId } } }
|
|
51
|
+
});
|
|
52
|
+
const stateMap = new Map(workflowStates.nodes.map(s => [s.name, s.id]));
|
|
53
|
+
const testingStateId = stateMap.get('Testing');
|
|
54
|
+
|
|
55
|
+
// Phase 3: Read existing local state
|
|
56
|
+
const existingData = await loadCycleData();
|
|
57
|
+
const existingTasksMap = new Map(existingData?.tasks.map(t => [t.id, t]));
|
|
58
|
+
|
|
59
|
+
// Phase 4: Fetch current issues with full content
|
|
60
|
+
const filterLabel = localConfig.label ?? 'Frontend';
|
|
61
|
+
console.log(`Fetching issues with label: ${filterLabel}...`);
|
|
62
|
+
const issues = await client.issues({
|
|
63
|
+
filter: {
|
|
64
|
+
team: { id: { eq: teamId } },
|
|
65
|
+
cycle: { id: { eq: cycleId } },
|
|
66
|
+
labels: { name: { eq: filterLabel } },
|
|
67
|
+
state: { name: { in: ["Todo", "In Progress"] } }
|
|
68
|
+
},
|
|
69
|
+
first: 50
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (issues.nodes.length === 0) {
|
|
73
|
+
console.log(`No ${filterLabel} issues found in current cycle with Todo/In Progress status.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tasks: Task[] = [];
|
|
77
|
+
let updatedCount = 0;
|
|
78
|
+
|
|
79
|
+
for (const issue of issues.nodes) {
|
|
80
|
+
const assignee = await issue.assignee;
|
|
81
|
+
const assigneeEmail = assignee?.email;
|
|
82
|
+
|
|
83
|
+
// Skip excluded assignees
|
|
84
|
+
if (assigneeEmail && excludedEmails.has(assigneeEmail)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const labels = await issue.labels();
|
|
89
|
+
const state = await issue.state;
|
|
90
|
+
const parent = await issue.parent;
|
|
91
|
+
const attachmentsData = await issue.attachments();
|
|
92
|
+
const commentsData = await issue.comments();
|
|
93
|
+
|
|
94
|
+
// Build attachments list
|
|
95
|
+
const attachments: Attachment[] = attachmentsData.nodes.map(a => ({
|
|
96
|
+
id: a.id,
|
|
97
|
+
title: a.title,
|
|
98
|
+
url: a.url,
|
|
99
|
+
sourceType: a.sourceType ?? undefined
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
// Build comments list
|
|
103
|
+
const comments: Comment[] = await Promise.all(
|
|
104
|
+
commentsData.nodes.map(async c => {
|
|
105
|
+
const user = await c.user;
|
|
106
|
+
return {
|
|
107
|
+
id: c.id,
|
|
108
|
+
body: c.body,
|
|
109
|
+
createdAt: c.createdAt.toISOString(),
|
|
110
|
+
user: user?.displayName ?? user?.email
|
|
111
|
+
};
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
let localStatus: Task['localStatus'] = 'pending';
|
|
116
|
+
|
|
117
|
+
// Preserve local status & sync completed tasks to Linear
|
|
118
|
+
if (existingTasksMap.has(issue.identifier)) {
|
|
119
|
+
const existing = existingTasksMap.get(issue.identifier)!;
|
|
120
|
+
localStatus = existing.localStatus;
|
|
121
|
+
|
|
122
|
+
if (localStatus === 'completed' && state && testingStateId) {
|
|
123
|
+
if (!['Testing', 'Done', 'In Review', 'Canceled'].includes(state.name)) {
|
|
124
|
+
console.log(`Updating ${issue.identifier} to Testing in Linear...`);
|
|
125
|
+
await client.updateIssue(issue.id, { stateId: testingStateId });
|
|
126
|
+
updatedCount++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const task: Task = {
|
|
132
|
+
id: issue.identifier,
|
|
133
|
+
linearId: issue.id,
|
|
134
|
+
title: issue.title,
|
|
135
|
+
status: state ? state.name : 'Unknown',
|
|
136
|
+
localStatus: localStatus,
|
|
137
|
+
assignee: assigneeEmail,
|
|
138
|
+
priority: issue.priority,
|
|
139
|
+
labels: labels.nodes.map(l => l.name),
|
|
140
|
+
branch: issue.branchName,
|
|
141
|
+
description: issue.description,
|
|
142
|
+
parentIssueId: parent ? parent.identifier : undefined,
|
|
143
|
+
url: issue.url,
|
|
144
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
145
|
+
comments: comments.length > 0 ? comments : undefined
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
tasks.push(task);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Sort by priority (urgent first)
|
|
152
|
+
tasks.sort((a, b) => {
|
|
153
|
+
const pa = a.priority === 0 ? 5 : a.priority;
|
|
154
|
+
const pb = b.priority === 0 ? 5 : b.priority;
|
|
155
|
+
return pa - pb;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const newData: CycleData = {
|
|
159
|
+
cycleId: cycleId,
|
|
160
|
+
cycleName: config.current_cycle.name,
|
|
161
|
+
updatedAt: new Date().toISOString(),
|
|
162
|
+
tasks: tasks
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
await saveCycleData(newData);
|
|
166
|
+
console.log(`\nā
Synced ${tasks.length} tasks for ${config.current_cycle.name}.`);
|
|
167
|
+
if (updatedCount > 0) {
|
|
168
|
+
console.log(` Updated ${updatedCount} issues to Testing in Linear.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
sync().catch(console.error);
|