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.
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env bun
2
+ import prompts from "prompts";
3
+ import { getLinearClient, getTeamId, loadConfig, loadLocalConfig, saveConfig, saveLocalConfig, } from "./utils.js";
4
+ async function config() {
5
+ const args = process.argv.slice(2);
6
+ // Handle help flag
7
+ if (args.includes("--help") || args.includes("-h")) {
8
+ console.log(`Usage: ttt config [subcommand]
9
+
10
+ Subcommands:
11
+ show Show current configuration
12
+ status Configure status mappings (todo, in_progress, done, testing)
13
+ filters Configure filters (label, exclude_labels, exclude_assignees)
14
+ teams Configure team selection
15
+
16
+ Examples:
17
+ ttt config # Show current config
18
+ ttt config show # Show current config
19
+ ttt config status # Configure status mappings
20
+ ttt config filters # Configure filter settings
21
+ ttt config teams # Configure team selection`);
22
+ process.exit(0);
23
+ }
24
+ const subcommand = args[0] || "show";
25
+ const configData = await loadConfig();
26
+ const localConfig = await loadLocalConfig();
27
+ if (subcommand === "show") {
28
+ console.log("📋 Current Configuration:\n");
29
+ // Teams
30
+ console.log("Teams:");
31
+ if (localConfig.teams && localConfig.teams.length > 0) {
32
+ console.log(` Selected: ${localConfig.teams.join(", ")}`);
33
+ console.log(` Primary: ${localConfig.team}`);
34
+ }
35
+ else {
36
+ console.log(` ${localConfig.team}`);
37
+ }
38
+ // User
39
+ console.log(`\nUser: ${localConfig.current_user}`);
40
+ // Filters
41
+ console.log("\nFilters:");
42
+ console.log(` Label: ${localConfig.label || "(all)"}`);
43
+ console.log(` Exclude labels: ${localConfig.exclude_labels?.join(", ") || "(none)"}`);
44
+ console.log(` Exclude users: ${localConfig.exclude_assignees?.join(", ") || "(none)"}`);
45
+ // Status Mappings
46
+ console.log("\nStatus Mappings:");
47
+ if (configData.status_transitions) {
48
+ const st = configData.status_transitions;
49
+ console.log(` Todo: ${st.todo}`);
50
+ console.log(` In Progress: ${st.in_progress}`);
51
+ console.log(` Done: ${st.done}`);
52
+ if (st.testing) {
53
+ console.log(` Testing: ${st.testing}`);
54
+ }
55
+ }
56
+ else {
57
+ console.log(" (not configured)");
58
+ }
59
+ process.exit(0);
60
+ }
61
+ if (subcommand === "status") {
62
+ console.log("📊 Configure Status Mappings\n");
63
+ const client = getLinearClient();
64
+ const teamId = getTeamId(configData, localConfig.team);
65
+ // Fetch workflow states
66
+ const statesData = await client.workflowStates({
67
+ filter: { team: { id: { eq: teamId } } },
68
+ });
69
+ const states = statesData.nodes;
70
+ if (states.length === 0) {
71
+ console.error("No workflow states found for this team.");
72
+ process.exit(1);
73
+ }
74
+ const stateChoices = states.map((s) => ({
75
+ title: `${s.name} (${s.type})`,
76
+ value: s.name,
77
+ }));
78
+ // Get current values or defaults
79
+ const current = configData.status_transitions || {};
80
+ const defaultTodo = current.todo ||
81
+ states.find((s) => s.type === "unstarted")?.name ||
82
+ "Todo";
83
+ const defaultInProgress = current.in_progress ||
84
+ states.find((s) => s.type === "started")?.name ||
85
+ "In Progress";
86
+ const defaultDone = current.done ||
87
+ states.find((s) => s.type === "completed")?.name ||
88
+ "Done";
89
+ const defaultTesting = current.testing || states.find((s) => s.name === "Testing")?.name;
90
+ const todoResponse = await prompts({
91
+ type: "select",
92
+ name: "todo",
93
+ message: 'Select status for "Todo" (pending tasks):',
94
+ choices: stateChoices,
95
+ initial: stateChoices.findIndex((c) => c.value === defaultTodo),
96
+ });
97
+ const inProgressResponse = await prompts({
98
+ type: "select",
99
+ name: "in_progress",
100
+ message: 'Select status for "In Progress" (working tasks):',
101
+ choices: stateChoices,
102
+ initial: stateChoices.findIndex((c) => c.value === defaultInProgress),
103
+ });
104
+ const doneResponse = await prompts({
105
+ type: "select",
106
+ name: "done",
107
+ message: 'Select status for "Done" (completed tasks):',
108
+ choices: stateChoices,
109
+ initial: stateChoices.findIndex((c) => c.value === defaultDone),
110
+ });
111
+ // Testing is optional
112
+ const testingChoices = [
113
+ { title: "(None)", value: undefined },
114
+ ...stateChoices,
115
+ ];
116
+ const testingResponse = await prompts({
117
+ type: "select",
118
+ name: "testing",
119
+ message: 'Select status for "Testing" (optional, for parent tasks):',
120
+ choices: testingChoices,
121
+ initial: defaultTesting
122
+ ? testingChoices.findIndex((c) => c.value === defaultTesting)
123
+ : 0,
124
+ });
125
+ const statusTransitions = {
126
+ todo: todoResponse.todo || defaultTodo,
127
+ in_progress: inProgressResponse.in_progress || defaultInProgress,
128
+ done: doneResponse.done || defaultDone,
129
+ testing: testingResponse.testing,
130
+ };
131
+ configData.status_transitions = statusTransitions;
132
+ await saveConfig(configData);
133
+ console.log("\n✅ Status mappings updated:");
134
+ console.log(` Todo: ${statusTransitions.todo}`);
135
+ console.log(` In Progress: ${statusTransitions.in_progress}`);
136
+ console.log(` Done: ${statusTransitions.done}`);
137
+ if (statusTransitions.testing) {
138
+ console.log(` Testing: ${statusTransitions.testing}`);
139
+ }
140
+ }
141
+ if (subcommand === "filters") {
142
+ console.log("🔍 Configure Filters\n");
143
+ const client = getLinearClient();
144
+ const teamId = getTeamId(configData, localConfig.team);
145
+ // Fetch labels
146
+ const labelsData = await client.issueLabels({
147
+ filter: { team: { id: { eq: teamId } } },
148
+ });
149
+ const labels = labelsData.nodes;
150
+ // Fetch users
151
+ const team = await client.team(teamId);
152
+ const members = await team.members();
153
+ const users = members.nodes;
154
+ // Label filter (optional)
155
+ const labelChoices = [
156
+ { title: "(No filter - sync all labels)", value: "" },
157
+ ...labels.map((l) => ({ title: l.name, value: l.name })),
158
+ ];
159
+ const labelResponse = await prompts({
160
+ type: "select",
161
+ name: "label",
162
+ message: "Select label filter (optional):",
163
+ choices: labelChoices,
164
+ initial: localConfig.label
165
+ ? labelChoices.findIndex((c) => c.value === localConfig.label)
166
+ : 0,
167
+ });
168
+ // Exclude labels
169
+ const excludeLabelsResponse = await prompts({
170
+ type: "multiselect",
171
+ name: "excludeLabels",
172
+ message: "Select labels to exclude (space to select):",
173
+ choices: labels.map((l) => ({
174
+ title: l.name,
175
+ value: l.name,
176
+ selected: localConfig.exclude_labels?.includes(l.name),
177
+ })),
178
+ });
179
+ // Exclude users
180
+ const excludeUsersResponse = await prompts({
181
+ type: "multiselect",
182
+ name: "excludeUsers",
183
+ message: "Select users to exclude (space to select):",
184
+ choices: users.map((u) => {
185
+ const key = (u.displayName ||
186
+ u.name ||
187
+ u.email?.split("@")[0] ||
188
+ "user")
189
+ .toLowerCase()
190
+ .replace(/[^a-z0-9]/g, "_");
191
+ return {
192
+ title: `${u.displayName || u.name} (${u.email})`,
193
+ value: key,
194
+ selected: localConfig.exclude_assignees?.includes(key),
195
+ };
196
+ }),
197
+ });
198
+ localConfig.label = labelResponse.label || undefined;
199
+ localConfig.exclude_labels =
200
+ excludeLabelsResponse.excludeLabels?.length > 0
201
+ ? excludeLabelsResponse.excludeLabels
202
+ : undefined;
203
+ localConfig.exclude_assignees =
204
+ excludeUsersResponse.excludeUsers?.length > 0
205
+ ? excludeUsersResponse.excludeUsers
206
+ : undefined;
207
+ await saveLocalConfig(localConfig);
208
+ console.log("\n✅ Filters updated:");
209
+ console.log(` Label: ${localConfig.label || "(all)"}`);
210
+ console.log(` Exclude labels: ${localConfig.exclude_labels?.join(", ") || "(none)"}`);
211
+ console.log(` Exclude users: ${localConfig.exclude_assignees?.join(", ") || "(none)"}`);
212
+ }
213
+ if (subcommand === "teams") {
214
+ console.log("👥 Configure Teams\n");
215
+ const client = getLinearClient();
216
+ const teamsData = await client.teams();
217
+ const teams = teamsData.nodes;
218
+ if (teams.length === 0) {
219
+ console.error("No teams found.");
220
+ process.exit(1);
221
+ }
222
+ // Multi-select teams
223
+ const teamsResponse = await prompts({
224
+ type: "multiselect",
225
+ name: "teamKeys",
226
+ message: "Select teams to sync (space to select):",
227
+ choices: teams.map((t) => {
228
+ const key = t.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
229
+ const currentTeams = localConfig.teams || [localConfig.team];
230
+ return {
231
+ title: t.name,
232
+ value: key,
233
+ selected: currentTeams.includes(key),
234
+ };
235
+ }),
236
+ min: 1,
237
+ });
238
+ const selectedTeamKeys = teamsResponse.teamKeys || [localConfig.team];
239
+ // If multiple teams, ask for primary
240
+ let primaryTeam = localConfig.team;
241
+ if (selectedTeamKeys.length > 1) {
242
+ const primaryResponse = await prompts({
243
+ type: "select",
244
+ name: "primary",
245
+ message: "Select primary team:",
246
+ choices: selectedTeamKeys.map((key) => ({
247
+ title: key,
248
+ value: key,
249
+ })),
250
+ initial: selectedTeamKeys.indexOf(localConfig.team),
251
+ });
252
+ primaryTeam = primaryResponse.primary || selectedTeamKeys[0];
253
+ }
254
+ else {
255
+ primaryTeam = selectedTeamKeys[0];
256
+ }
257
+ localConfig.team = primaryTeam;
258
+ localConfig.teams =
259
+ selectedTeamKeys.length > 1 ? selectedTeamKeys : undefined;
260
+ await saveLocalConfig(localConfig);
261
+ console.log("\n✅ Teams updated:");
262
+ if (localConfig.teams) {
263
+ console.log(` Selected: ${localConfig.teams.join(", ")}`);
264
+ console.log(` Primary: ${localConfig.team}`);
265
+ }
266
+ else {
267
+ console.log(` Team: ${localConfig.team}`);
268
+ }
269
+ }
270
+ }
271
+ config().catch(console.error);
@@ -1,27 +1,37 @@
1
- import prompts from 'prompts';
2
- import { execSync } from 'node:child_process';
3
- import { getLinearClient, loadConfig, loadLocalConfig, loadCycleData, saveCycleData, getTeamId } from './utils.js';
1
+ import { execSync } from "node:child_process";
2
+ import prompts from "prompts";
3
+ import { getLinearClient, getTeamId, loadConfig, loadCycleData, loadLocalConfig, saveCycleData, } from "./utils.js";
4
4
  async function getLatestCommit() {
5
5
  try {
6
- const shortHash = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
7
- const fullHash = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
8
- const message = execSync('git log -1 --format=%s', { encoding: 'utf-8' }).trim();
9
- const diffStat = execSync('git diff HEAD~1 --stat --stat-width=60', { encoding: 'utf-8' }).trim();
6
+ const shortHash = execSync("git rev-parse --short HEAD", {
7
+ encoding: "utf-8",
8
+ }).trim();
9
+ const fullHash = execSync("git rev-parse HEAD", {
10
+ encoding: "utf-8",
11
+ }).trim();
12
+ const message = execSync("git log -1 --format=%s", {
13
+ encoding: "utf-8",
14
+ }).trim();
15
+ const diffStat = execSync("git diff HEAD~1 --stat --stat-width=60", {
16
+ encoding: "utf-8",
17
+ }).trim();
10
18
  // Get remote URL and construct commit link
11
19
  let commitUrl = null;
12
20
  try {
13
- const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
21
+ const remoteUrl = execSync("git remote get-url origin", {
22
+ encoding: "utf-8",
23
+ }).trim();
14
24
  // Handle SSH or HTTPS URLs
15
25
  // git@gitlab.com:org/repo.git -> https://gitlab.com/org/repo/-/commit/hash
16
26
  // https://gitlab.com/org/repo.git -> https://gitlab.com/org/repo/-/commit/hash
17
- if (remoteUrl.includes('gitlab')) {
18
- const match = remoteUrl.match(/(?:git@|https:\/\/)([^:\/]+)[:\\/](.+?)(?:\.git)?$/);
27
+ if (remoteUrl.includes("gitlab")) {
28
+ const match = remoteUrl.match(/(?:git@|https:\/\/)([^:/]+)[:\\/](.+?)(?:\.git)?$/);
19
29
  if (match) {
20
30
  commitUrl = `https://${match[1]}/${match[2]}/-/commit/${fullHash}`;
21
31
  }
22
32
  }
23
- else if (remoteUrl.includes('github')) {
24
- const match = remoteUrl.match(/(?:git@|https:\/\/)([^:\/]+)[:\\/](.+?)(?:\.git)?$/);
33
+ else if (remoteUrl.includes("github")) {
34
+ const match = remoteUrl.match(/(?:git@|https:\/\/)([^:/]+)[:\\/](.+?)(?:\.git)?$/);
25
35
  if (match) {
26
36
  commitUrl = `https://${match[1]}/${match[2]}/commit/${fullHash}`;
27
37
  }
@@ -41,10 +51,10 @@ function parseArgs(args) {
41
51
  let message;
42
52
  for (let i = 0; i < args.length; i++) {
43
53
  const arg = args[i];
44
- if (arg === '-m' || arg === '--message') {
54
+ if (arg === "-m" || arg === "--message") {
45
55
  message = args[++i];
46
56
  }
47
- else if (!arg.startsWith('-')) {
57
+ else if (!arg.startsWith("-")) {
48
58
  issueId = arg;
49
59
  }
50
60
  }
@@ -53,7 +63,7 @@ function parseArgs(args) {
53
63
  async function doneJob() {
54
64
  const args = process.argv.slice(2);
55
65
  // Handle help flag
56
- if (args.includes('--help') || args.includes('-h')) {
66
+ if (args.includes("--help") || args.includes("-h")) {
57
67
  console.log(`Usage: ttt done [issue-id] [-m message]
58
68
 
59
69
  Arguments:
@@ -75,13 +85,13 @@ Examples:
75
85
  const localConfig = await loadLocalConfig();
76
86
  const data = await loadCycleData();
77
87
  if (!data) {
78
- console.error('No cycle data found. Run /sync-linear first.');
88
+ console.error("No cycle data found. Run /sync-linear first.");
79
89
  process.exit(1);
80
90
  }
81
91
  // Find in-progress tasks
82
- const inProgressTasks = data.tasks.filter(t => t.localStatus === 'in-progress');
92
+ const inProgressTasks = data.tasks.filter((t) => t.localStatus === "in-progress");
83
93
  if (inProgressTasks.length === 0) {
84
- console.log('沒有進行中的任務');
94
+ console.log("沒有進行中的任務");
85
95
  process.exit(0);
86
96
  }
87
97
  // Phase 0: Issue Resolution
@@ -91,63 +101,68 @@ Examples:
91
101
  console.log(`Auto-selected: ${issueId}`);
92
102
  }
93
103
  else if (process.stdin.isTTY) {
94
- const choices = inProgressTasks.map(task => ({
104
+ const choices = inProgressTasks.map((task) => ({
95
105
  title: `${task.id}: ${task.title}`,
96
106
  value: task.id,
97
- description: task.labels.join(', ')
107
+ description: task.labels.join(", "),
98
108
  }));
99
109
  const response = await prompts({
100
- type: 'select',
101
- name: 'issueId',
102
- message: '選擇要完成的任務:',
103
- choices: choices
110
+ type: "select",
111
+ name: "issueId",
112
+ message: "選擇要完成的任務:",
113
+ choices: choices,
104
114
  });
105
115
  if (!response.issueId) {
106
- console.log('已取消');
116
+ console.log("已取消");
107
117
  process.exit(0);
108
118
  }
109
119
  issueId = response.issueId;
110
120
  }
111
121
  else {
112
- console.error('多個進行中任務,請指定 issue ID:');
113
- inProgressTasks.forEach(t => console.log(` - ${t.id}: ${t.title}`));
122
+ console.error("多個進行中任務,請指定 issue ID:");
123
+ for (const t of inProgressTasks) {
124
+ console.log(` - ${t.id}: ${t.title}`);
125
+ }
114
126
  process.exit(1);
115
127
  }
116
128
  }
117
129
  // Phase 1: Find task
118
- const task = data.tasks.find(t => t.id === issueId || t.id === `MP-${issueId}`);
130
+ const task = data.tasks.find((t) => t.id === issueId || t.id === `MP-${issueId}`);
119
131
  if (!task) {
120
132
  console.error(`Issue ${issueId} not found in current cycle.`);
121
133
  process.exit(1);
122
134
  }
123
- if (task.localStatus !== 'in-progress') {
135
+ if (task.localStatus !== "in-progress") {
124
136
  console.log(`⚠️ 任務 ${task.id} 不在進行中狀態 (目前: ${task.localStatus})`);
125
137
  process.exit(1);
126
138
  }
127
139
  // Get latest commit for comment
128
140
  const commit = await getLatestCommit();
129
141
  // Phase 2: Get AI summary message
130
- let aiMessage = argMessage || '';
142
+ let aiMessage = argMessage || "";
131
143
  if (!aiMessage && process.stdin.isTTY) {
132
144
  const aiMsgResponse = await prompts({
133
- type: 'text',
134
- name: 'aiMessage',
135
- message: 'AI 修復說明 (如何解決此問題):',
145
+ type: "text",
146
+ name: "aiMessage",
147
+ message: "AI 修復說明 (如何解決此問題):",
136
148
  });
137
- aiMessage = aiMsgResponse.aiMessage || '';
149
+ aiMessage = aiMsgResponse.aiMessage || "";
138
150
  }
139
151
  // Phase 3: Update Linear
140
152
  if (task.linearId && process.env.LINEAR_API_KEY) {
141
153
  try {
142
154
  const client = getLinearClient();
143
155
  const workflowStates = await client.workflowStates({
144
- filter: { team: { id: { eq: getTeamId(config, localConfig.team) } } }
156
+ filter: { team: { id: { eq: getTeamId(config, localConfig.team) } } },
145
157
  });
146
- const doneState = workflowStates.nodes.find(s => s.name === 'Done');
158
+ // Get status names from config or use defaults
159
+ const doneStatusName = config.status_transitions?.done || "Done";
160
+ const testingStatusName = config.status_transitions?.testing || "Testing";
161
+ const doneState = workflowStates.nodes.find((s) => s.name === doneStatusName);
147
162
  // Update issue to Done
148
163
  if (doneState) {
149
164
  await client.updateIssue(task.linearId, { stateId: doneState.id });
150
- console.log(`Linear: ${task.id} → Done`);
165
+ console.log(`Linear: ${task.id} → ${doneStatusName}`);
151
166
  }
152
167
  // Add comment with commit info and AI summary
153
168
  if (commit) {
@@ -155,64 +170,66 @@ Examples:
155
170
  ? `[${commit.shortHash}](${commit.commitUrl})`
156
171
  : `\`${commit.shortHash}\``;
157
172
  const commentParts = [
158
- '## ✅ 開發完成',
159
- '',
160
- '### 🤖 AI 修復說明',
161
- aiMessage || '_No description provided_',
162
- '',
163
- '### 📝 Commit Info',
173
+ "## ✅ 開發完成",
174
+ "",
175
+ "### 🤖 AI 修復說明",
176
+ aiMessage || "_No description provided_",
177
+ "",
178
+ "### 📝 Commit Info",
164
179
  `**Commit:** ${commitLink}`,
165
180
  `**Message:** ${commit.message}`,
166
- '',
167
- '### 📊 Changes',
168
- '```',
181
+ "",
182
+ "### 📊 Changes",
183
+ "```",
169
184
  commit.diffStat,
170
- '```',
185
+ "```",
171
186
  ];
172
187
  await client.createComment({
173
188
  issueId: task.linearId,
174
- body: commentParts.join('\n')
189
+ body: commentParts.join("\n"),
175
190
  });
176
191
  console.log(`Linear: 已新增 commit 留言`);
177
192
  }
178
- // Update parent to Testing if exists
179
- if (task.parentIssueId) {
193
+ // Update parent to Testing if exists and testing status is configured
194
+ if (task.parentIssueId && testingStatusName) {
180
195
  try {
181
196
  // Find parent issue by identifier
182
197
  const searchResult = await client.searchIssues(task.parentIssueId);
183
- const parentIssue = searchResult.nodes.find(issue => issue.identifier === task.parentIssueId);
198
+ const parentIssue = searchResult.nodes.find((issue) => issue.identifier === task.parentIssueId);
184
199
  if (parentIssue) {
185
200
  // Get parent's team workflow states
186
201
  const parentTeam = await parentIssue.team;
187
202
  if (parentTeam) {
188
203
  const parentWorkflowStates = await client.workflowStates({
189
- filter: { team: { id: { eq: parentTeam.id } } }
204
+ filter: { team: { id: { eq: parentTeam.id } } },
190
205
  });
191
- const testingState = parentWorkflowStates.nodes.find(s => s.name === 'Testing');
206
+ const testingState = parentWorkflowStates.nodes.find((s) => s.name === testingStatusName);
192
207
  if (testingState) {
193
- await client.updateIssue(parentIssue.id, { stateId: testingState.id });
194
- console.log(`Linear: Parent ${task.parentIssueId} → Testing`);
208
+ await client.updateIssue(parentIssue.id, {
209
+ stateId: testingState.id,
210
+ });
211
+ console.log(`Linear: Parent ${task.parentIssueId} → ${testingStatusName}`);
195
212
  }
196
213
  }
197
214
  }
198
215
  }
199
216
  catch (parentError) {
200
- console.error('Failed to update parent issue:', parentError);
217
+ console.error("Failed to update parent issue:", parentError);
201
218
  }
202
219
  }
203
220
  }
204
221
  catch (e) {
205
- console.error('Failed to update Linear:', e);
222
+ console.error("Failed to update Linear:", e);
206
223
  }
207
224
  }
208
225
  // Phase 4: Update local status
209
- task.localStatus = 'completed';
226
+ task.localStatus = "completed";
210
227
  await saveCycleData(data);
211
228
  console.log(`Local: ${task.id} → completed`);
212
229
  // Phase 5: Summary
213
- console.log(`\n${''.repeat(50)}`);
230
+ console.log(`\n${"".repeat(50)}`);
214
231
  console.log(`✅ ${task.id}: ${task.title}`);
215
- console.log(`${''.repeat(50)}`);
232
+ console.log(`${"".repeat(50)}`);
216
233
  if (commit) {
217
234
  console.log(`Commit: ${commit.shortHash} - ${commit.message}`);
218
235
  if (commit.commitUrl) {
@@ -222,8 +239,8 @@ Examples:
222
239
  if (aiMessage) {
223
240
  console.log(`AI: ${aiMessage}`);
224
241
  }
225
- if (task.parentIssueId) {
226
- console.log(`Parent: ${task.parentIssueId} → Testing`);
242
+ if (task.parentIssueId && config.status_transitions?.testing) {
243
+ console.log(`Parent: ${task.parentIssueId} → ${config.status_transitions.testing}`);
227
244
  }
228
245
  console.log(`\n🎉 任務完成!`);
229
246
  }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};