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.
@@ -1,263 +0,0 @@
1
- import prompts from 'prompts';
2
- import { execSync } from 'node:child_process';
3
- import { getLinearClient, loadConfig, loadLocalConfig, loadCycleData, saveCycleData, getTeamId, getDefaultTeamKey } from './utils.js';
4
-
5
- interface CommitInfo {
6
- shortHash: string;
7
- fullHash: string;
8
- message: string;
9
- diffStat: string;
10
- commitUrl: string | null;
11
- }
12
-
13
- async function getLatestCommit(): Promise<CommitInfo | null> {
14
- try {
15
- const shortHash = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
16
- const fullHash = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
17
- const message = execSync('git log -1 --format=%s', { encoding: 'utf-8' }).trim();
18
- const diffStat = execSync('git diff HEAD~1 --stat --stat-width=60', { encoding: 'utf-8' }).trim();
19
-
20
- // Get remote URL and construct commit link
21
- let commitUrl: string | null = null;
22
- try {
23
- const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
24
- // Handle SSH or HTTPS URLs
25
- // git@gitlab.com:org/repo.git -> https://gitlab.com/org/repo/-/commit/hash
26
- // https://gitlab.com/org/repo.git -> https://gitlab.com/org/repo/-/commit/hash
27
- if (remoteUrl.includes('gitlab')) {
28
- const match = remoteUrl.match(/(?:git@|https:\/\/)([^:\/]+)[:\\/](.+?)(?:\.git)?$/);
29
- if (match) {
30
- commitUrl = `https://${match[1]}/${match[2]}/-/commit/${fullHash}`;
31
- }
32
- } else if (remoteUrl.includes('github')) {
33
- const match = remoteUrl.match(/(?:git@|https:\/\/)([^:\/]+)[:\\/](.+?)(?:\.git)?$/);
34
- if (match) {
35
- commitUrl = `https://${match[1]}/${match[2]}/commit/${fullHash}`;
36
- }
37
- }
38
- } catch {
39
- // Ignore if can't get remote URL
40
- }
41
-
42
- return { shortHash, fullHash, message, diffStat, commitUrl };
43
- } catch {
44
- return null;
45
- }
46
- }
47
-
48
- function parseArgs(args: string[]): { issueId?: string; message?: string } {
49
- let issueId: string | undefined;
50
- let message: string | undefined;
51
-
52
- for (let i = 0; i < args.length; i++) {
53
- const arg = args[i];
54
- if (arg === '-m' || arg === '--message') {
55
- message = args[++i];
56
- } else if (!arg.startsWith('-')) {
57
- issueId = arg;
58
- }
59
- }
60
-
61
- return { issueId, message };
62
- }
63
-
64
- async function doneJob() {
65
- const args = process.argv.slice(2);
66
-
67
- // Handle help flag
68
- if (args.includes('--help') || args.includes('-h')) {
69
- console.log(`Usage: ttt done [issue-id] [-m message]
70
-
71
- Arguments:
72
- issue-id Issue ID (e.g., MP-624). Optional if only one task is in-progress
73
-
74
- Options:
75
- -m, --message AI summary message describing the fix
76
-
77
- Examples:
78
- ttt done # Complete current in-progress task
79
- ttt done MP-624 # Complete specific task
80
- ttt done -m "Fixed null check" # With completion message
81
- ttt done MP-624 -m "Refactored" # Specific task with message`);
82
- process.exit(0);
83
- }
84
-
85
- const { issueId: argIssueId, message: argMessage } = parseArgs(args);
86
- let issueId = argIssueId;
87
-
88
- const config = await loadConfig();
89
- const localConfig = await loadLocalConfig();
90
- const data = await loadCycleData();
91
-
92
- if (!data) {
93
- console.error('No cycle data found. Run /sync-linear first.');
94
- process.exit(1);
95
- }
96
-
97
- // Find in-progress tasks
98
- const inProgressTasks = data.tasks.filter(t => t.localStatus === 'in-progress');
99
-
100
- if (inProgressTasks.length === 0) {
101
- console.log('沒有進行中的任務');
102
- process.exit(0);
103
- }
104
-
105
- // Phase 0: Issue Resolution
106
- if (!issueId) {
107
- if (inProgressTasks.length === 1) {
108
- issueId = inProgressTasks[0].id;
109
- console.log(`Auto-selected: ${issueId}`);
110
- } else if (process.stdin.isTTY) {
111
- const choices = inProgressTasks.map(task => ({
112
- title: `${task.id}: ${task.title}`,
113
- value: task.id,
114
- description: task.labels.join(', ')
115
- }));
116
-
117
- const response = await prompts({
118
- type: 'select',
119
- name: 'issueId',
120
- message: '選擇要完成的任務:',
121
- choices: choices
122
- });
123
-
124
- if (!response.issueId) {
125
- console.log('已取消');
126
- process.exit(0);
127
- }
128
- issueId = response.issueId;
129
- } else {
130
- console.error('多個進行中任務,請指定 issue ID:');
131
- inProgressTasks.forEach(t => console.log(` - ${t.id}: ${t.title}`));
132
- process.exit(1);
133
- }
134
- }
135
-
136
- // Phase 1: Find task
137
- const task = data.tasks.find(t => t.id === issueId || t.id === `MP-${issueId}`);
138
- if (!task) {
139
- console.error(`Issue ${issueId} not found in current cycle.`);
140
- process.exit(1);
141
- }
142
-
143
- if (task.localStatus !== 'in-progress') {
144
- console.log(`⚠️ 任務 ${task.id} 不在進行中狀態 (目前: ${task.localStatus})`);
145
- process.exit(1);
146
- }
147
-
148
- // Get latest commit for comment
149
- const commit = await getLatestCommit();
150
-
151
- // Phase 2: Get AI summary message
152
- let aiMessage = argMessage || '';
153
- if (!aiMessage && process.stdin.isTTY) {
154
- const aiMsgResponse = await prompts({
155
- type: 'text',
156
- name: 'aiMessage',
157
- message: 'AI 修復說明 (如何解決此問題):',
158
- });
159
- aiMessage = aiMsgResponse.aiMessage || '';
160
- }
161
-
162
- // Phase 3: Update Linear
163
- if (task.linearId && process.env.LINEAR_API_KEY) {
164
- try {
165
- const client = getLinearClient();
166
- const workflowStates = await client.workflowStates({
167
- filter: { team: { id: { eq: getTeamId(config, localConfig.team) } } }
168
- });
169
- const doneState = workflowStates.nodes.find(s => s.name === 'Done');
170
-
171
- // Update issue to Done
172
- if (doneState) {
173
- await client.updateIssue(task.linearId, { stateId: doneState.id });
174
- console.log(`Linear: ${task.id} → Done`);
175
- }
176
-
177
- // Add comment with commit info and AI summary
178
- if (commit) {
179
- const commitLink = commit.commitUrl
180
- ? `[${commit.shortHash}](${commit.commitUrl})`
181
- : `\`${commit.shortHash}\``;
182
-
183
- const commentParts = [
184
- '## ✅ 開發完成',
185
- '',
186
- '### 🤖 AI 修復說明',
187
- aiMessage || '_No description provided_',
188
- '',
189
- '### 📝 Commit Info',
190
- `**Commit:** ${commitLink}`,
191
- `**Message:** ${commit.message}`,
192
- '',
193
- '### 📊 Changes',
194
- '```',
195
- commit.diffStat,
196
- '```',
197
- ];
198
-
199
- await client.createComment({
200
- issueId: task.linearId,
201
- body: commentParts.join('\n')
202
- });
203
- console.log(`Linear: 已新增 commit 留言`);
204
- }
205
-
206
- // Update parent to Testing if exists
207
- if (task.parentIssueId) {
208
- try {
209
- // Find parent issue by identifier
210
- const searchResult = await client.searchIssues(task.parentIssueId);
211
- const parentIssue = searchResult.nodes.find(
212
- issue => issue.identifier === task.parentIssueId
213
- );
214
-
215
- if (parentIssue) {
216
- // Get parent's team workflow states
217
- const parentTeam = await parentIssue.team;
218
- if (parentTeam) {
219
- const parentWorkflowStates = await client.workflowStates({
220
- filter: { team: { id: { eq: parentTeam.id } } }
221
- });
222
- const testingState = parentWorkflowStates.nodes.find(s => s.name === 'Testing');
223
-
224
- if (testingState) {
225
- await client.updateIssue(parentIssue.id, { stateId: testingState.id });
226
- console.log(`Linear: Parent ${task.parentIssueId} → Testing`);
227
- }
228
- }
229
- }
230
- } catch (parentError) {
231
- console.error('Failed to update parent issue:', parentError);
232
- }
233
- }
234
- } catch (e) {
235
- console.error('Failed to update Linear:', e);
236
- }
237
- }
238
-
239
- // Phase 4: Update local status
240
- task.localStatus = 'completed';
241
- await saveCycleData(data);
242
- console.log(`Local: ${task.id} → completed`);
243
-
244
- // Phase 5: Summary
245
- console.log(`\n${'═'.repeat(50)}`);
246
- console.log(`✅ ${task.id}: ${task.title}`);
247
- console.log(`${'═'.repeat(50)}`);
248
- if (commit) {
249
- console.log(`Commit: ${commit.shortHash} - ${commit.message}`);
250
- if (commit.commitUrl) {
251
- console.log(`URL: ${commit.commitUrl}`);
252
- }
253
- }
254
- if (aiMessage) {
255
- console.log(`AI: ${aiMessage}`);
256
- }
257
- if (task.parentIssueId) {
258
- console.log(`Parent: ${task.parentIssueId} → Testing`);
259
- }
260
- console.log(`\n🎉 任務完成!`);
261
- }
262
-
263
- doneJob().catch(console.error);
package/scripts/init.js DELETED
@@ -1,331 +0,0 @@
1
- #!/usr/bin/env bun
2
- import fs from 'node:fs/promises';
3
- import prompts from 'prompts';
4
- import { encode, decode } from '@toon-format/toon';
5
- import { getLinearClient, getPaths, fileExists } from './utils.js';
6
- function parseArgs(args) {
7
- const options = { interactive: true };
8
- for (let i = 0; i < args.length; i++) {
9
- const arg = args[i];
10
- switch (arg) {
11
- case '--api-key':
12
- case '-k':
13
- options.apiKey = args[++i];
14
- break;
15
- case '--user':
16
- case '-u':
17
- options.user = args[++i];
18
- break;
19
- case '--team':
20
- case '-t':
21
- options.team = args[++i];
22
- break;
23
- case '--label':
24
- case '-l':
25
- options.label = args[++i];
26
- break;
27
- case '--force':
28
- case '-f':
29
- options.force = true;
30
- break;
31
- case '--yes':
32
- case '-y':
33
- options.interactive = false;
34
- break;
35
- case '--help':
36
- case '-h':
37
- printHelp();
38
- process.exit(0);
39
- }
40
- }
41
- return options;
42
- }
43
- function printHelp() {
44
- console.log(`
45
- linear-toon init - Initialize configuration files
46
-
47
- USAGE:
48
- bun run init [OPTIONS]
49
-
50
- OPTIONS:
51
- -k, --api-key <key> Linear API key (or set LINEAR_API_KEY env)
52
- -u, --user <email> Your email address in Linear
53
- -t, --team <name> Team name to sync (optional, fetches from Linear)
54
- -l, --label <name> Default label filter (e.g., Frontend, Backend)
55
- -f, --force Overwrite existing config files
56
- -y, --yes Non-interactive mode (use defaults/provided args)
57
- -h, --help Show this help message
58
-
59
- EXAMPLES:
60
- bun run init
61
- bun run init --user alice@example.com --label Frontend
62
- bun run init -k lin_api_xxx -y
63
- `);
64
- }
65
- async function init() {
66
- const args = process.argv.slice(2);
67
- const options = parseArgs(args);
68
- const paths = getPaths();
69
- console.log('🚀 Linear-TOON Initialization\n');
70
- // Check existing files
71
- const configExists = await fileExists(paths.configPath);
72
- const localExists = await fileExists(paths.localPath);
73
- if ((configExists || localExists) && !options.force) {
74
- console.log('Existing configuration found:');
75
- if (configExists)
76
- console.log(` ✓ ${paths.configPath}`);
77
- if (localExists)
78
- console.log(` ✓ ${paths.localPath}`);
79
- if (options.interactive) {
80
- const { proceed } = await prompts({
81
- type: 'confirm',
82
- name: 'proceed',
83
- message: 'Update existing configuration?',
84
- initial: true
85
- });
86
- if (!proceed) {
87
- console.log('Cancelled.');
88
- process.exit(0);
89
- }
90
- }
91
- else {
92
- console.log('Use --force to overwrite existing files.');
93
- process.exit(1);
94
- }
95
- }
96
- // Get API key
97
- let apiKey = options.apiKey || process.env.LINEAR_API_KEY;
98
- if (!apiKey && options.interactive) {
99
- const response = await prompts({
100
- type: 'password',
101
- name: 'apiKey',
102
- message: 'Enter your Linear API key:',
103
- validate: v => v.startsWith('lin_api_') ? true : 'API key should start with "lin_api_"'
104
- });
105
- apiKey = response.apiKey;
106
- }
107
- if (!apiKey) {
108
- console.error('Error: LINEAR_API_KEY is required.');
109
- console.error('Get your API key from: https://linear.app/settings/api');
110
- process.exit(1);
111
- }
112
- // Create Linear client
113
- const client = getLinearClient();
114
- console.log('\n📡 Fetching data from Linear...');
115
- // Fetch teams
116
- const teamsData = await client.teams();
117
- const teams = teamsData.nodes;
118
- if (teams.length === 0) {
119
- console.error('Error: No teams found in your Linear workspace.');
120
- process.exit(1);
121
- }
122
- // Select team
123
- let selectedTeam = teams[0];
124
- if (options.team) {
125
- const found = teams.find(t => t.name.toLowerCase() === options.team.toLowerCase());
126
- if (found)
127
- selectedTeam = found;
128
- }
129
- else if (options.interactive && teams.length > 1) {
130
- const response = await prompts({
131
- type: 'select',
132
- name: 'teamId',
133
- message: 'Select your primary team:',
134
- choices: teams.map(t => ({ title: t.name, value: t.id }))
135
- });
136
- selectedTeam = teams.find(t => t.id === response.teamId) || teams[0];
137
- }
138
- console.log(` Team: ${selectedTeam.name}`);
139
- // Fetch team members
140
- const members = await selectedTeam.members();
141
- const users = members.nodes;
142
- console.log(` Users: ${users.length}`);
143
- // Fetch labels
144
- const labelsData = await client.issueLabels({
145
- filter: { team: { id: { eq: selectedTeam.id } } }
146
- });
147
- const labels = labelsData.nodes;
148
- console.log(` Labels: ${labels.length}`);
149
- // Fetch workflow states
150
- const statesData = await client.workflowStates({
151
- filter: { team: { id: { eq: selectedTeam.id } } }
152
- });
153
- const states = statesData.nodes;
154
- // Fetch current cycle using activeCycle (direct and accurate)
155
- const currentCycle = await selectedTeam.activeCycle;
156
- // Select current user
157
- let currentUser = users[0];
158
- if (options.user) {
159
- const found = users.find(u => u.email?.toLowerCase() === options.user.toLowerCase() ||
160
- u.displayName?.toLowerCase() === options.user.toLowerCase());
161
- if (found)
162
- currentUser = found;
163
- }
164
- else if (options.interactive) {
165
- const response = await prompts({
166
- type: 'select',
167
- name: 'userId',
168
- message: 'Select yourself:',
169
- choices: users.map(u => ({
170
- title: `${u.displayName || u.name} (${u.email})`,
171
- value: u.id
172
- }))
173
- });
174
- currentUser = users.find(u => u.id === response.userId) || users[0];
175
- }
176
- // Select default label
177
- let defaultLabel = labels[0]?.name || 'Frontend';
178
- if (options.label) {
179
- defaultLabel = options.label;
180
- }
181
- else if (options.interactive && labels.length > 0) {
182
- const response = await prompts({
183
- type: 'select',
184
- name: 'label',
185
- message: 'Select default label filter:',
186
- choices: labels.map(l => ({ title: l.name, value: l.name }))
187
- });
188
- defaultLabel = response.label || defaultLabel;
189
- }
190
- // Build config
191
- const teamsConfig = {};
192
- for (const team of teams) {
193
- const key = team.name.toLowerCase().replace(/[^a-z0-9]/g, '_');
194
- teamsConfig[key] = {
195
- id: team.id,
196
- name: team.name,
197
- icon: team.icon || undefined
198
- };
199
- }
200
- const usersConfig = {};
201
- for (const user of users) {
202
- const key = (user.displayName || user.name || user.email?.split('@')[0] || 'user')
203
- .toLowerCase()
204
- .replace(/[^a-z0-9]/g, '_');
205
- usersConfig[key] = {
206
- id: user.id,
207
- email: user.email || '',
208
- displayName: user.displayName || user.name || ''
209
- };
210
- }
211
- const labelsConfig = {};
212
- for (const label of labels) {
213
- const key = label.name.toLowerCase().replace(/[^a-z0-9]/g, '_');
214
- labelsConfig[key] = {
215
- id: label.id,
216
- name: label.name,
217
- color: label.color || undefined
218
- };
219
- }
220
- const statusesConfig = {};
221
- for (const state of states) {
222
- const key = state.name.toLowerCase().replace(/[^a-z0-9]/g, '_');
223
- statusesConfig[key] = {
224
- name: state.name,
225
- type: state.type
226
- };
227
- }
228
- const config = {
229
- teams: teamsConfig,
230
- users: usersConfig,
231
- labels: labelsConfig,
232
- priorities: {
233
- urgent: { value: 1, name: 'Urgent' },
234
- high: { value: 2, name: 'High' },
235
- medium: { value: 3, name: 'Medium' },
236
- low: { value: 4, name: 'Low' }
237
- },
238
- statuses: statusesConfig,
239
- status_transitions: {
240
- start_work: 'In Progress',
241
- complete: 'Done',
242
- need_review: 'In Review'
243
- },
244
- priority_order: ['urgent', 'high', 'medium', 'low', 'none'],
245
- current_cycle: currentCycle ? {
246
- id: currentCycle.id,
247
- name: currentCycle.name || `Cycle #${currentCycle.number}`,
248
- start_date: currentCycle.startsAt?.toISOString().split('T')[0] || '',
249
- end_date: currentCycle.endsAt?.toISOString().split('T')[0] || ''
250
- } : undefined,
251
- cycle_history: []
252
- };
253
- // Find current user key
254
- const currentUserKey = Object.entries(usersConfig).find(([_, u]) => u.id === currentUser.id)?.[0] || 'user';
255
- // Find selected team key
256
- const selectedTeamKey = Object.entries(teamsConfig).find(([_, t]) => t.id === selectedTeam.id)?.[0] || Object.keys(teamsConfig)[0];
257
- const localConfig = {
258
- current_user: currentUserKey,
259
- team: selectedTeamKey,
260
- label: defaultLabel
261
- };
262
- // Write config files
263
- console.log('\n📝 Writing configuration files...');
264
- // Ensure directory exists
265
- await fs.mkdir(paths.baseDir, { recursive: true });
266
- // Merge with existing config if exists
267
- if (configExists && !options.force) {
268
- try {
269
- const existingContent = await fs.readFile(paths.configPath, 'utf-8');
270
- const existingConfig = decode(existingContent);
271
- // Merge: preserve existing custom fields
272
- config.status_transitions = {
273
- ...existingConfig.status_transitions,
274
- ...config.status_transitions
275
- };
276
- // Preserve cycle history
277
- if (existingConfig.cycle_history) {
278
- config.cycle_history = existingConfig.cycle_history;
279
- }
280
- // Preserve current_cycle if not fetched fresh
281
- if (!currentCycle && existingConfig.current_cycle) {
282
- config.current_cycle = existingConfig.current_cycle;
283
- }
284
- // Preserve priority_order if exists
285
- if (existingConfig.priority_order) {
286
- config.priority_order = existingConfig.priority_order;
287
- }
288
- }
289
- catch {
290
- // Ignore merge errors
291
- }
292
- }
293
- await fs.writeFile(paths.configPath, encode(config), 'utf-8');
294
- console.log(` ✓ ${paths.configPath}`);
295
- // Merge local config
296
- if (localExists && !options.force) {
297
- try {
298
- const existingContent = await fs.readFile(paths.localPath, 'utf-8');
299
- const existingLocal = decode(existingContent);
300
- // Preserve existing values
301
- if (existingLocal.current_user)
302
- localConfig.current_user = existingLocal.current_user;
303
- if (existingLocal.team)
304
- localConfig.team = existingLocal.team;
305
- if (existingLocal.label)
306
- localConfig.label = existingLocal.label;
307
- if (existingLocal.exclude_assignees)
308
- localConfig.exclude_assignees = existingLocal.exclude_assignees;
309
- }
310
- catch {
311
- // Ignore merge errors
312
- }
313
- }
314
- await fs.writeFile(paths.localPath, encode(localConfig), 'utf-8');
315
- console.log(` ✓ ${paths.localPath}`);
316
- // Summary
317
- console.log('\n✅ Initialization complete!\n');
318
- console.log('Configuration summary:');
319
- console.log(` Team: ${selectedTeam.name}`);
320
- console.log(` User: ${currentUser.displayName || currentUser.name} (${currentUser.email})`);
321
- console.log(` Label: ${defaultLabel}`);
322
- if (currentCycle) {
323
- console.log(` Cycle: ${currentCycle.name || `Cycle #${currentCycle.number}`}`);
324
- }
325
- console.log('\nNext steps:');
326
- console.log(' 1. Set LINEAR_API_KEY in your shell profile:');
327
- console.log(` export LINEAR_API_KEY="${apiKey}"`);
328
- console.log(' 2. Run sync: bun run sync');
329
- console.log(' 3. Start working: bun run work-on');
330
- }
331
- init().catch(console.error);