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.
@@ -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);
@@ -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);