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/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);