team-toon-tack 1.0.12 → 1.6.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 +59 -8
- package/README.zh-TW.md +111 -22
- package/{bin → dist/bin}/cli.js +41 -19
- package/dist/scripts/config.js +271 -0
- package/{scripts → dist/scripts}/done-job.js +79 -62
- package/dist/scripts/init.d.ts +2 -0
- package/dist/scripts/init.js +492 -0
- package/dist/scripts/status.d.ts +2 -0
- package/dist/scripts/status.js +251 -0
- package/dist/scripts/sync.js +247 -0
- package/{scripts → dist/scripts}/utils.d.ts +11 -3
- package/{scripts → dist/scripts}/utils.js +33 -27
- package/{scripts → dist/scripts}/work-on.js +55 -41
- package/package.json +52 -50
- package/templates/claude-code-commands/done-job.md +45 -0
- package/templates/claude-code-commands/sync-linear.md +32 -0
- package/templates/claude-code-commands/work-on.md +41 -0
- package/bin/cli.ts +0 -125
- package/scripts/done-job.ts +0 -263
- package/scripts/init.js +0 -331
- package/scripts/init.ts +0 -375
- package/scripts/sync.js +0 -178
- package/scripts/sync.ts +0 -211
- package/scripts/utils.ts +0 -236
- package/scripts/work-on.ts +0 -161
- /package/{bin → dist/bin}/cli.d.ts +0 -0
- /package/{scripts/init.d.ts → dist/scripts/config.d.ts} +0 -0
- /package/{scripts → dist/scripts}/done-job.d.ts +0 -0
- /package/{scripts → dist/scripts}/sync.d.ts +0 -0
- /package/{scripts → dist/scripts}/work-on.d.ts +0 -0
package/scripts/init.ts
DELETED
|
@@ -1,375 +0,0 @@
|
|
|
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.js';
|
|
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 using activeCycle (direct and accurate)
|
|
181
|
-
const currentCycle = await selectedTeam.activeCycle;
|
|
182
|
-
|
|
183
|
-
// Select current user
|
|
184
|
-
let currentUser = users[0];
|
|
185
|
-
if (options.user) {
|
|
186
|
-
const found = users.find(u =>
|
|
187
|
-
u.email?.toLowerCase() === options.user!.toLowerCase() ||
|
|
188
|
-
u.displayName?.toLowerCase() === options.user!.toLowerCase()
|
|
189
|
-
);
|
|
190
|
-
if (found) currentUser = found;
|
|
191
|
-
} else if (options.interactive) {
|
|
192
|
-
const response = await prompts({
|
|
193
|
-
type: 'select',
|
|
194
|
-
name: 'userId',
|
|
195
|
-
message: 'Select yourself:',
|
|
196
|
-
choices: users.map(u => ({
|
|
197
|
-
title: `${u.displayName || u.name} (${u.email})`,
|
|
198
|
-
value: u.id
|
|
199
|
-
}))
|
|
200
|
-
});
|
|
201
|
-
currentUser = users.find(u => u.id === response.userId) || users[0];
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Select default label
|
|
205
|
-
let defaultLabel = labels[0]?.name || 'Frontend';
|
|
206
|
-
if (options.label) {
|
|
207
|
-
defaultLabel = options.label;
|
|
208
|
-
} else if (options.interactive && labels.length > 0) {
|
|
209
|
-
const response = await prompts({
|
|
210
|
-
type: 'select',
|
|
211
|
-
name: 'label',
|
|
212
|
-
message: 'Select default label filter:',
|
|
213
|
-
choices: labels.map(l => ({ title: l.name, value: l.name }))
|
|
214
|
-
});
|
|
215
|
-
defaultLabel = response.label || defaultLabel;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Build config
|
|
219
|
-
const teamsConfig: Record<string, { id: string; name: string; icon?: string }> = {};
|
|
220
|
-
for (const team of teams) {
|
|
221
|
-
const key = team.name.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
222
|
-
teamsConfig[key] = {
|
|
223
|
-
id: team.id,
|
|
224
|
-
name: team.name,
|
|
225
|
-
icon: team.icon || undefined
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const usersConfig: Record<string, { id: string; email: string; displayName: string; role?: string }> = {};
|
|
230
|
-
for (const user of users) {
|
|
231
|
-
const key = (user.displayName || user.name || user.email?.split('@')[0] || 'user')
|
|
232
|
-
.toLowerCase()
|
|
233
|
-
.replace(/[^a-z0-9]/g, '_');
|
|
234
|
-
usersConfig[key] = {
|
|
235
|
-
id: user.id,
|
|
236
|
-
email: user.email || '',
|
|
237
|
-
displayName: user.displayName || user.name || ''
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const labelsConfig: Record<string, { id: string; name: string; color?: string }> = {};
|
|
242
|
-
for (const label of labels) {
|
|
243
|
-
const key = label.name.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
244
|
-
labelsConfig[key] = {
|
|
245
|
-
id: label.id,
|
|
246
|
-
name: label.name,
|
|
247
|
-
color: label.color || undefined
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const statusesConfig: Record<string, { name: string; type: string }> = {};
|
|
252
|
-
for (const state of states) {
|
|
253
|
-
const key = state.name.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
254
|
-
statusesConfig[key] = {
|
|
255
|
-
name: state.name,
|
|
256
|
-
type: state.type
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const config: Config = {
|
|
261
|
-
teams: teamsConfig,
|
|
262
|
-
users: usersConfig,
|
|
263
|
-
labels: labelsConfig,
|
|
264
|
-
priorities: {
|
|
265
|
-
urgent: { value: 1, name: 'Urgent' },
|
|
266
|
-
high: { value: 2, name: 'High' },
|
|
267
|
-
medium: { value: 3, name: 'Medium' },
|
|
268
|
-
low: { value: 4, name: 'Low' }
|
|
269
|
-
},
|
|
270
|
-
statuses: statusesConfig,
|
|
271
|
-
status_transitions: {
|
|
272
|
-
start_work: 'In Progress',
|
|
273
|
-
complete: 'Done',
|
|
274
|
-
need_review: 'In Review'
|
|
275
|
-
},
|
|
276
|
-
priority_order: ['urgent', 'high', 'medium', 'low', 'none'],
|
|
277
|
-
current_cycle: currentCycle ? {
|
|
278
|
-
id: currentCycle.id,
|
|
279
|
-
name: currentCycle.name || `Cycle #${currentCycle.number}`,
|
|
280
|
-
start_date: currentCycle.startsAt?.toISOString().split('T')[0] || '',
|
|
281
|
-
end_date: currentCycle.endsAt?.toISOString().split('T')[0] || ''
|
|
282
|
-
} : undefined,
|
|
283
|
-
cycle_history: []
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
// Find current user key
|
|
287
|
-
const currentUserKey = Object.entries(usersConfig).find(
|
|
288
|
-
([_, u]) => u.id === currentUser.id
|
|
289
|
-
)?.[0] || 'user';
|
|
290
|
-
|
|
291
|
-
// Find selected team key
|
|
292
|
-
const selectedTeamKey = Object.entries(teamsConfig).find(
|
|
293
|
-
([_, t]) => t.id === selectedTeam.id
|
|
294
|
-
)?.[0] || Object.keys(teamsConfig)[0];
|
|
295
|
-
|
|
296
|
-
const localConfig: LocalConfig = {
|
|
297
|
-
current_user: currentUserKey,
|
|
298
|
-
team: selectedTeamKey,
|
|
299
|
-
label: defaultLabel
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
// Write config files
|
|
303
|
-
console.log('\n📝 Writing configuration files...');
|
|
304
|
-
|
|
305
|
-
// Ensure directory exists
|
|
306
|
-
await fs.mkdir(paths.baseDir, { recursive: true });
|
|
307
|
-
|
|
308
|
-
// Merge with existing config if exists
|
|
309
|
-
if (configExists && !options.force) {
|
|
310
|
-
try {
|
|
311
|
-
const existingContent = await fs.readFile(paths.configPath, 'utf-8');
|
|
312
|
-
const existingConfig = decode(existingContent) as unknown as Config;
|
|
313
|
-
|
|
314
|
-
// Merge: preserve existing custom fields
|
|
315
|
-
config.status_transitions = {
|
|
316
|
-
...existingConfig.status_transitions,
|
|
317
|
-
...config.status_transitions
|
|
318
|
-
};
|
|
319
|
-
// Preserve cycle history
|
|
320
|
-
if (existingConfig.cycle_history) {
|
|
321
|
-
config.cycle_history = existingConfig.cycle_history;
|
|
322
|
-
}
|
|
323
|
-
// Preserve current_cycle if not fetched fresh
|
|
324
|
-
if (!currentCycle && existingConfig.current_cycle) {
|
|
325
|
-
config.current_cycle = existingConfig.current_cycle;
|
|
326
|
-
}
|
|
327
|
-
// Preserve priority_order if exists
|
|
328
|
-
if (existingConfig.priority_order) {
|
|
329
|
-
config.priority_order = existingConfig.priority_order;
|
|
330
|
-
}
|
|
331
|
-
} catch {
|
|
332
|
-
// Ignore merge errors
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
await fs.writeFile(paths.configPath, encode(config), 'utf-8');
|
|
337
|
-
console.log(` ✓ ${paths.configPath}`);
|
|
338
|
-
|
|
339
|
-
// Merge local config
|
|
340
|
-
if (localExists && !options.force) {
|
|
341
|
-
try {
|
|
342
|
-
const existingContent = await fs.readFile(paths.localPath, 'utf-8');
|
|
343
|
-
const existingLocal = decode(existingContent) as unknown as LocalConfig;
|
|
344
|
-
|
|
345
|
-
// Preserve existing values
|
|
346
|
-
if (existingLocal.current_user) localConfig.current_user = existingLocal.current_user;
|
|
347
|
-
if (existingLocal.team) localConfig.team = existingLocal.team;
|
|
348
|
-
if (existingLocal.label) localConfig.label = existingLocal.label;
|
|
349
|
-
if (existingLocal.exclude_assignees) localConfig.exclude_assignees = existingLocal.exclude_assignees;
|
|
350
|
-
} catch {
|
|
351
|
-
// Ignore merge errors
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
await fs.writeFile(paths.localPath, encode(localConfig), 'utf-8');
|
|
356
|
-
console.log(` ✓ ${paths.localPath}`);
|
|
357
|
-
|
|
358
|
-
// Summary
|
|
359
|
-
console.log('\n✅ Initialization complete!\n');
|
|
360
|
-
console.log('Configuration summary:');
|
|
361
|
-
console.log(` Team: ${selectedTeam.name}`);
|
|
362
|
-
console.log(` User: ${currentUser.displayName || currentUser.name} (${currentUser.email})`);
|
|
363
|
-
console.log(` Label: ${defaultLabel}`);
|
|
364
|
-
if (currentCycle) {
|
|
365
|
-
console.log(` Cycle: ${currentCycle.name || `Cycle #${currentCycle.number}`}`);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
console.log('\nNext steps:');
|
|
369
|
-
console.log(' 1. Set LINEAR_API_KEY in your shell profile:');
|
|
370
|
-
console.log(` export LINEAR_API_KEY="${apiKey}"`);
|
|
371
|
-
console.log(' 2. Run sync: bun run sync');
|
|
372
|
-
console.log(' 3. Start working: bun run work-on');
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
init().catch(console.error);
|
package/scripts/sync.js
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import { getLinearClient, loadConfig, loadLocalConfig, loadCycleData, saveCycleData, saveConfig, getTeamId, getPrioritySortIndex } from './utils.js';
|
|
2
|
-
async function sync() {
|
|
3
|
-
const args = process.argv.slice(2);
|
|
4
|
-
// Handle help flag
|
|
5
|
-
if (args.includes('--help') || args.includes('-h')) {
|
|
6
|
-
console.log(`Usage: ttt sync
|
|
7
|
-
|
|
8
|
-
Sync issues from Linear to local cycle.ttt file.
|
|
9
|
-
|
|
10
|
-
What it does:
|
|
11
|
-
- Fetches active cycle from Linear
|
|
12
|
-
- Downloads all issues matching configured label
|
|
13
|
-
- Preserves local status for existing tasks
|
|
14
|
-
- Updates config with new cycle info
|
|
15
|
-
|
|
16
|
-
Examples:
|
|
17
|
-
ttt sync # Sync in current directory
|
|
18
|
-
ttt sync -d .ttt # Sync using .ttt directory`);
|
|
19
|
-
process.exit(0);
|
|
20
|
-
}
|
|
21
|
-
const config = await loadConfig();
|
|
22
|
-
const localConfig = await loadLocalConfig();
|
|
23
|
-
const client = getLinearClient();
|
|
24
|
-
const teamId = getTeamId(config, localConfig.team);
|
|
25
|
-
// Build excluded emails from local config
|
|
26
|
-
const excludedEmails = new Set((localConfig.exclude_assignees ?? [])
|
|
27
|
-
.map(key => config.users[key]?.email)
|
|
28
|
-
.filter(Boolean));
|
|
29
|
-
// Phase 1: Fetch active cycle directly from team
|
|
30
|
-
console.log('Fetching latest cycle...');
|
|
31
|
-
const team = await client.team(teamId);
|
|
32
|
-
const activeCycle = await team.activeCycle;
|
|
33
|
-
if (!activeCycle) {
|
|
34
|
-
console.error('No active cycle found.');
|
|
35
|
-
process.exit(1);
|
|
36
|
-
}
|
|
37
|
-
const cycleId = activeCycle.id;
|
|
38
|
-
const cycleName = activeCycle.name ?? `Cycle #${activeCycle.number}`;
|
|
39
|
-
const newCycleInfo = {
|
|
40
|
-
id: cycleId,
|
|
41
|
-
name: cycleName,
|
|
42
|
-
start_date: activeCycle.startsAt?.toISOString().split('T')[0] ?? '',
|
|
43
|
-
end_date: activeCycle.endsAt?.toISOString().split('T')[0] ?? ''
|
|
44
|
-
};
|
|
45
|
-
// Check if cycle changed and update config with history
|
|
46
|
-
const existingData = await loadCycleData();
|
|
47
|
-
const oldCycleId = config.current_cycle?.id ?? existingData?.cycleId;
|
|
48
|
-
if (oldCycleId && oldCycleId !== cycleId) {
|
|
49
|
-
const oldCycleName = config.current_cycle?.name ?? existingData?.cycleName ?? 'Unknown';
|
|
50
|
-
console.log(`Cycle changed: ${oldCycleName} → ${cycleName}`);
|
|
51
|
-
// Move old cycle to history (avoid duplicates)
|
|
52
|
-
if (config.current_cycle) {
|
|
53
|
-
config.cycle_history = config.cycle_history ?? [];
|
|
54
|
-
// Remove if already exists in history
|
|
55
|
-
config.cycle_history = config.cycle_history.filter(c => c.id !== config.current_cycle.id);
|
|
56
|
-
config.cycle_history.unshift(config.current_cycle);
|
|
57
|
-
// Keep only last 10 cycles
|
|
58
|
-
if (config.cycle_history.length > 10) {
|
|
59
|
-
config.cycle_history = config.cycle_history.slice(0, 10);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
// Update current cycle
|
|
63
|
-
config.current_cycle = newCycleInfo;
|
|
64
|
-
await saveConfig(config);
|
|
65
|
-
console.log('Config updated with new cycle (old cycle saved to history).');
|
|
66
|
-
}
|
|
67
|
-
else {
|
|
68
|
-
// Update current cycle info even if ID unchanged (dates might change)
|
|
69
|
-
if (!config.current_cycle || config.current_cycle.id !== cycleId) {
|
|
70
|
-
config.current_cycle = newCycleInfo;
|
|
71
|
-
await saveConfig(config);
|
|
72
|
-
}
|
|
73
|
-
console.log(`Current cycle: ${cycleName}`);
|
|
74
|
-
}
|
|
75
|
-
// Phase 2: Fetch workflow states
|
|
76
|
-
const workflowStates = await client.workflowStates({
|
|
77
|
-
filter: { team: { id: { eq: teamId } } }
|
|
78
|
-
});
|
|
79
|
-
const stateMap = new Map(workflowStates.nodes.map(s => [s.name, s.id]));
|
|
80
|
-
const testingStateId = stateMap.get('Testing');
|
|
81
|
-
// Phase 3: Build existing tasks map for preserving local status
|
|
82
|
-
const existingTasksMap = new Map(existingData?.tasks.map(t => [t.id, t]));
|
|
83
|
-
// Phase 4: Fetch current issues with full content
|
|
84
|
-
const filterLabel = localConfig.label ?? 'Frontend';
|
|
85
|
-
console.log(`Fetching issues with label: ${filterLabel}...`);
|
|
86
|
-
const issues = await client.issues({
|
|
87
|
-
filter: {
|
|
88
|
-
team: { id: { eq: teamId } },
|
|
89
|
-
cycle: { id: { eq: cycleId } },
|
|
90
|
-
labels: { name: { eq: filterLabel } },
|
|
91
|
-
state: { name: { in: ["Todo", "In Progress"] } }
|
|
92
|
-
},
|
|
93
|
-
first: 50
|
|
94
|
-
});
|
|
95
|
-
if (issues.nodes.length === 0) {
|
|
96
|
-
console.log(`No ${filterLabel} issues found in current cycle with Todo/In Progress status.`);
|
|
97
|
-
}
|
|
98
|
-
const tasks = [];
|
|
99
|
-
let updatedCount = 0;
|
|
100
|
-
for (const issue of issues.nodes) {
|
|
101
|
-
const assignee = await issue.assignee;
|
|
102
|
-
const assigneeEmail = assignee?.email;
|
|
103
|
-
// Skip excluded assignees
|
|
104
|
-
if (assigneeEmail && excludedEmails.has(assigneeEmail)) {
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
const labels = await issue.labels();
|
|
108
|
-
const state = await issue.state;
|
|
109
|
-
const parent = await issue.parent;
|
|
110
|
-
const attachmentsData = await issue.attachments();
|
|
111
|
-
const commentsData = await issue.comments();
|
|
112
|
-
// Build attachments list
|
|
113
|
-
const attachments = attachmentsData.nodes.map(a => ({
|
|
114
|
-
id: a.id,
|
|
115
|
-
title: a.title,
|
|
116
|
-
url: a.url,
|
|
117
|
-
sourceType: a.sourceType ?? undefined
|
|
118
|
-
}));
|
|
119
|
-
// Build comments list
|
|
120
|
-
const comments = await Promise.all(commentsData.nodes.map(async (c) => {
|
|
121
|
-
const user = await c.user;
|
|
122
|
-
return {
|
|
123
|
-
id: c.id,
|
|
124
|
-
body: c.body,
|
|
125
|
-
createdAt: c.createdAt.toISOString(),
|
|
126
|
-
user: user?.displayName ?? user?.email
|
|
127
|
-
};
|
|
128
|
-
}));
|
|
129
|
-
let localStatus = 'pending';
|
|
130
|
-
// Preserve local status & sync completed tasks to Linear
|
|
131
|
-
if (existingTasksMap.has(issue.identifier)) {
|
|
132
|
-
const existing = existingTasksMap.get(issue.identifier);
|
|
133
|
-
localStatus = existing.localStatus;
|
|
134
|
-
if (localStatus === 'completed' && state && testingStateId) {
|
|
135
|
-
if (!['Testing', 'Done', 'In Review', 'Canceled'].includes(state.name)) {
|
|
136
|
-
console.log(`Updating ${issue.identifier} to Testing in Linear...`);
|
|
137
|
-
await client.updateIssue(issue.id, { stateId: testingStateId });
|
|
138
|
-
updatedCount++;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
const task = {
|
|
143
|
-
id: issue.identifier,
|
|
144
|
-
linearId: issue.id,
|
|
145
|
-
title: issue.title,
|
|
146
|
-
status: state ? state.name : 'Unknown',
|
|
147
|
-
localStatus: localStatus,
|
|
148
|
-
assignee: assigneeEmail,
|
|
149
|
-
priority: issue.priority,
|
|
150
|
-
labels: labels.nodes.map(l => l.name),
|
|
151
|
-
branch: issue.branchName,
|
|
152
|
-
description: issue.description,
|
|
153
|
-
parentIssueId: parent ? parent.identifier : undefined,
|
|
154
|
-
url: issue.url,
|
|
155
|
-
attachments: attachments.length > 0 ? attachments : undefined,
|
|
156
|
-
comments: comments.length > 0 ? comments : undefined
|
|
157
|
-
};
|
|
158
|
-
tasks.push(task);
|
|
159
|
-
}
|
|
160
|
-
// Sort by priority using config order
|
|
161
|
-
tasks.sort((a, b) => {
|
|
162
|
-
const pa = getPrioritySortIndex(a.priority, config.priority_order);
|
|
163
|
-
const pb = getPrioritySortIndex(b.priority, config.priority_order);
|
|
164
|
-
return pa - pb;
|
|
165
|
-
});
|
|
166
|
-
const newData = {
|
|
167
|
-
cycleId: cycleId,
|
|
168
|
-
cycleName: cycleName,
|
|
169
|
-
updatedAt: new Date().toISOString(),
|
|
170
|
-
tasks: tasks
|
|
171
|
-
};
|
|
172
|
-
await saveCycleData(newData);
|
|
173
|
-
console.log(`\n✅ Synced ${tasks.length} tasks for ${cycleName}.`);
|
|
174
|
-
if (updatedCount > 0) {
|
|
175
|
-
console.log(` Updated ${updatedCount} issues to Testing in Linear.`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
sync().catch(console.error);
|