team-toon-tack 2.0.3 โ†’ 2.3.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,4 +1,5 @@
1
1
  import prompts from "prompts";
2
+ import { getDefaultStatusTransitions } from "../lib/config-builder.js";
2
3
  import { getLinearClient, saveLocalConfig, } from "../utils.js";
3
4
  export async function configureTeams(_config, localConfig) {
4
5
  console.log("๐Ÿ‘ฅ Configure Teams\n");
@@ -9,51 +10,182 @@ export async function configureTeams(_config, localConfig) {
9
10
  console.error("No teams found.");
10
11
  process.exit(1);
11
12
  }
12
- // Multi-select teams
13
- const teamsResponse = await prompts({
14
- type: "multiselect",
15
- name: "teamKeys",
16
- message: "Select teams to sync (space to select):",
13
+ // Build team key mapping
14
+ const teamKeyMap = new Map(); // id -> key
15
+ for (const team of teams) {
16
+ const key = team.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
17
+ teamKeyMap.set(team.id, key);
18
+ }
19
+ // Fetch workflow states for all teams
20
+ const teamStatesMap = new Map();
21
+ for (const team of teams) {
22
+ try {
23
+ const statesData = await client.workflowStates({
24
+ filter: { team: { id: { eq: team.id } } },
25
+ });
26
+ teamStatesMap.set(team.id, statesData.nodes);
27
+ }
28
+ catch {
29
+ teamStatesMap.set(team.id, []);
30
+ }
31
+ }
32
+ // 1. Select dev team (single)
33
+ console.log("\n๐Ÿ‘จโ€๐Ÿ’ป Dev Team:");
34
+ const devTeamResponse = await prompts({
35
+ type: "select",
36
+ name: "teamId",
37
+ message: "Select your dev team:",
17
38
  choices: teams.map((t) => {
18
- const key = t.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
19
- const currentTeams = localConfig.teams || [localConfig.team];
39
+ const key = teamKeyMap.get(t.id) || "";
20
40
  return {
21
41
  title: t.name,
22
- value: key,
23
- selected: currentTeams.includes(key),
42
+ value: t.id,
43
+ selected: key === localConfig.team,
24
44
  };
25
45
  }),
26
- min: 1,
27
46
  });
28
- const selectedTeamKeys = teamsResponse.teamKeys || [localConfig.team];
29
- // If multiple teams, ask for primary
30
- let primaryTeam = localConfig.team;
31
- if (selectedTeamKeys.length > 1) {
32
- const primaryResponse = await prompts({
33
- type: "select",
34
- name: "primary",
35
- message: "Select primary team:",
36
- choices: selectedTeamKeys.map((key) => ({
37
- title: key,
38
- value: key,
47
+ const devTeamId = devTeamResponse.teamId;
48
+ const devTeamKey = teamKeyMap.get(devTeamId) || localConfig.team;
49
+ const devTeam = teams.find((t) => t.id === devTeamId);
50
+ // 2. Select dev team testing status
51
+ const devStates = teamStatesMap.get(devTeamId) || [];
52
+ const devDefaults = getDefaultStatusTransitions(devStates);
53
+ console.log("\n๐Ÿ” Dev Team Testing/Review Status:");
54
+ const devTestingResponse = await prompts({
55
+ type: "select",
56
+ name: "testingStatus",
57
+ message: "Select testing/review status for dev team:",
58
+ choices: [
59
+ { title: "(Skip - no testing status)", value: undefined },
60
+ ...devStates.map((s) => ({
61
+ title: `${s.name} (${s.type})`,
62
+ value: s.name,
39
63
  })),
40
- initial: selectedTeamKeys.indexOf(localConfig.team),
64
+ ],
65
+ initial: localConfig.dev_testing_status
66
+ ? devStates.findIndex((s) => s.name === localConfig.dev_testing_status) +
67
+ 1
68
+ : devDefaults.testing
69
+ ? devStates.findIndex((s) => s.name === devDefaults.testing) + 1
70
+ : 0,
71
+ });
72
+ const devTestingStatus = devTestingResponse.testingStatus;
73
+ // 3. Select QA/PM teams (multiple)
74
+ const otherTeams = teams.filter((t) => t.id !== devTeamId);
75
+ const qaPmTeams = [];
76
+ if (otherTeams.length > 0) {
77
+ console.log("\n๐Ÿ”— QA/PM Teams:");
78
+ const qaPmResponse = await prompts({
79
+ type: "multiselect",
80
+ name: "teamIds",
81
+ message: "Select QA/PM teams for cross-team parent updates (space to select):",
82
+ choices: otherTeams.map((t) => {
83
+ const key = teamKeyMap.get(t.id) || "";
84
+ const currentQaPm = localConfig.qa_pm_teams || [];
85
+ return {
86
+ title: t.name,
87
+ value: t.id,
88
+ selected: currentQaPm.some((qp) => qp.team === key),
89
+ };
90
+ }),
91
+ hint: "- Press space to select, enter to confirm. Leave empty to skip.",
41
92
  });
42
- primaryTeam = primaryResponse.primary || selectedTeamKeys[0];
93
+ const selectedQaPmIds = qaPmResponse.teamIds || [];
94
+ // 4. For each QA/PM team, select testing status
95
+ for (const teamId of selectedQaPmIds) {
96
+ const team = teams.find((t) => t.id === teamId);
97
+ if (!team)
98
+ continue;
99
+ const teamKey = teamKeyMap.get(teamId) || "";
100
+ const teamStates = teamStatesMap.get(teamId) || [];
101
+ const defaults = getDefaultStatusTransitions(teamStates);
102
+ // Find existing config for this team
103
+ const existingConfig = localConfig.qa_pm_teams?.find((qp) => qp.team === teamKey);
104
+ const stateChoices = teamStates.map((s) => ({
105
+ title: `${s.name} (${s.type})`,
106
+ value: s.name,
107
+ }));
108
+ const statusResponse = await prompts({
109
+ type: "select",
110
+ name: "testingStatus",
111
+ message: `Select testing status for ${team.name}:`,
112
+ choices: [
113
+ { title: "(Skip this team)", value: undefined },
114
+ ...stateChoices,
115
+ ],
116
+ initial: existingConfig?.testing_status
117
+ ? stateChoices.findIndex((c) => c.value === existingConfig.testing_status) + 1
118
+ : defaults.testing
119
+ ? stateChoices.findIndex((c) => c.value === defaults.testing) + 1
120
+ : 0,
121
+ });
122
+ if (statusResponse.testingStatus) {
123
+ qaPmTeams.push({
124
+ team: teamKey,
125
+ testing_status: statusResponse.testingStatus,
126
+ });
127
+ }
128
+ }
43
129
  }
44
- else {
45
- primaryTeam = selectedTeamKeys[0];
46
- }
47
- localConfig.team = primaryTeam;
48
- localConfig.teams =
49
- selectedTeamKeys.length > 1 ? selectedTeamKeys : undefined;
130
+ // 5. Select completion mode
131
+ console.log("\nโœ… Completion Mode:");
132
+ const currentMode = localConfig.completion_mode ||
133
+ (qaPmTeams.length > 0 ? "upstream_strict" : "simple");
134
+ const defaultModeIndex = currentMode === "simple"
135
+ ? 0
136
+ : currentMode === "strict_review"
137
+ ? 1
138
+ : currentMode === "upstream_strict"
139
+ ? 2
140
+ : 3;
141
+ const modeResponse = await prompts({
142
+ type: "select",
143
+ name: "mode",
144
+ message: "How should tasks be completed?",
145
+ choices: [
146
+ {
147
+ title: "Simple",
148
+ value: "simple",
149
+ description: "Mark task as done directly",
150
+ },
151
+ {
152
+ title: "Strict Review",
153
+ value: "strict_review",
154
+ description: "Mark task to dev team's testing status",
155
+ },
156
+ {
157
+ title: "Upstream Strict (recommended with QA/PM)",
158
+ value: "upstream_strict",
159
+ description: "Done + parent to testing, fallback to testing if no parent",
160
+ },
161
+ {
162
+ title: "Upstream Not Strict",
163
+ value: "upstream_not_strict",
164
+ description: "Done + parent to testing, no fallback",
165
+ },
166
+ ],
167
+ initial: defaultModeIndex,
168
+ });
169
+ const completionMode = modeResponse.mode || (qaPmTeams.length > 0 ? "upstream_strict" : "simple");
170
+ // Update local config
171
+ localConfig.team = devTeamKey;
172
+ localConfig.dev_testing_status = devTestingStatus;
173
+ localConfig.qa_pm_teams = qaPmTeams.length > 0 ? qaPmTeams : undefined;
174
+ localConfig.completion_mode = completionMode;
175
+ // Remove deprecated fields
176
+ delete localConfig.teams;
177
+ delete localConfig.qa_pm_team;
50
178
  await saveLocalConfig(localConfig);
51
179
  console.log("\nโœ… Teams updated:");
52
- if (localConfig.teams) {
53
- console.log(` Selected: ${localConfig.teams.join(", ")}`);
54
- console.log(` Primary: ${localConfig.team}`);
180
+ console.log(` Dev Team: ${devTeam?.name || devTeamKey}`);
181
+ if (devTestingStatus) {
182
+ console.log(` Dev Testing Status: ${devTestingStatus}`);
55
183
  }
56
- else {
57
- console.log(` Team: ${localConfig.team}`);
184
+ console.log(` Completion Mode: ${completionMode}`);
185
+ if (qaPmTeams.length > 0) {
186
+ console.log(" QA/PM Teams:");
187
+ for (const qp of qaPmTeams) {
188
+ console.log(` - ${qp.team}: ${qp.testing_status}`);
189
+ }
58
190
  }
59
191
  }
@@ -3,6 +3,86 @@ import { buildCompletionComment, getLatestCommit } from "./lib/git.js";
3
3
  import { addComment, getStatusTransitions, getWorkflowStates, updateIssueStatus, } from "./lib/linear.js";
4
4
  import { syncSingleIssue } from "./lib/sync.js";
5
5
  import { getLinearClient, loadConfig, loadCycleData, loadLocalConfig, } from "./utils.js";
6
+ // Helper function to update parent issue to a specific status
7
+ async function updateParentStatus(parentIssueId, targetStatus, _qaPmTeams, config) {
8
+ try {
9
+ const client = getLinearClient();
10
+ const searchResult = await client.searchIssues(parentIssueId);
11
+ const parentIssue = searchResult.nodes.find((issue) => issue.identifier === parentIssueId);
12
+ if (!parentIssue) {
13
+ return { success: false };
14
+ }
15
+ const parentTeam = await parentIssue.team;
16
+ if (!parentTeam) {
17
+ return { success: false };
18
+ }
19
+ // Find the matching team configuration
20
+ const teamEntries = Object.entries(config.teams);
21
+ const matchingTeamEntry = teamEntries.find(([_, t]) => t.id === parentTeam.id);
22
+ if (!matchingTeamEntry) {
23
+ return { success: false };
24
+ }
25
+ const [parentTeamKey] = matchingTeamEntry;
26
+ // Get workflow states for the parent's team
27
+ const parentStates = await getWorkflowStates(config, parentTeamKey);
28
+ const targetState = parentStates.find((s) => s.name === targetStatus);
29
+ if (!targetState) {
30
+ return { success: false };
31
+ }
32
+ // Update the parent issue
33
+ await client.updateIssue(parentIssue.id, {
34
+ stateId: targetState.id,
35
+ });
36
+ return { success: true, status: targetStatus };
37
+ }
38
+ catch (error) {
39
+ console.error("Failed to update parent issue:", error);
40
+ return { success: false };
41
+ }
42
+ }
43
+ // Helper function to update parent issue to testing status (uses QA team config)
44
+ async function updateParentToTesting(parentIssueId, qaPmTeams, config) {
45
+ try {
46
+ const client = getLinearClient();
47
+ const searchResult = await client.searchIssues(parentIssueId);
48
+ const parentIssue = searchResult.nodes.find((issue) => issue.identifier === parentIssueId);
49
+ if (!parentIssue) {
50
+ return { success: false };
51
+ }
52
+ const parentTeam = await parentIssue.team;
53
+ if (!parentTeam) {
54
+ return { success: false };
55
+ }
56
+ // Find the matching QA/PM team configuration
57
+ const teamEntries = Object.entries(config.teams);
58
+ const matchingTeamEntry = teamEntries.find(([_, t]) => t.id === parentTeam.id);
59
+ if (!matchingTeamEntry) {
60
+ return { success: false };
61
+ }
62
+ const [parentTeamKey] = matchingTeamEntry;
63
+ // Find the QA/PM team config for this parent's team
64
+ const qaPmConfig = qaPmTeams.find((qp) => qp.team === parentTeamKey);
65
+ if (!qaPmConfig) {
66
+ // Parent's team is not in the configured QA/PM teams
67
+ return { success: false };
68
+ }
69
+ // Get workflow states for the parent's team
70
+ const parentStates = await getWorkflowStates(config, parentTeamKey);
71
+ const testingState = parentStates.find((s) => s.name === qaPmConfig.testing_status);
72
+ if (!testingState) {
73
+ return { success: false };
74
+ }
75
+ // Update the parent issue
76
+ await client.updateIssue(parentIssue.id, {
77
+ stateId: testingState.id,
78
+ });
79
+ return { success: true, testingStatus: qaPmConfig.testing_status };
80
+ }
81
+ catch (error) {
82
+ console.error("Failed to update parent issue:", error);
83
+ return { success: false };
84
+ }
85
+ }
6
86
  function parseArgs(args) {
7
87
  let issueId;
8
88
  let message;
@@ -110,10 +190,84 @@ Examples:
110
190
  process.env.LINEAR_API_KEY &&
111
191
  statusSource === "remote") {
112
192
  const transitions = getStatusTransitions(config);
113
- // Update issue to Done
114
- const success = await updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
115
- if (success) {
116
- console.log(`Linear: ${task.id} โ†’ ${transitions.done}`);
193
+ // Determine completion mode
194
+ const completionMode = localConfig.completion_mode ||
195
+ (localConfig.qa_pm_teams && localConfig.qa_pm_teams.length > 0
196
+ ? "upstream_strict"
197
+ : "simple");
198
+ // Get the testing status to use (from dev team)
199
+ const devTestingStatus = localConfig.dev_testing_status || transitions.testing;
200
+ // Execute based on completion mode
201
+ let parentUpdateSuccess = false;
202
+ let parentTestingStatus;
203
+ switch (completionMode) {
204
+ case "simple": {
205
+ // Mark task as done
206
+ const success = await updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
207
+ if (success) {
208
+ console.log(`Linear: ${task.id} โ†’ ${transitions.done}`);
209
+ }
210
+ // Also mark parent as done if exists
211
+ if (task.parentIssueId) {
212
+ const result = await updateParentStatus(task.parentIssueId, transitions.done, localConfig.qa_pm_teams, config);
213
+ if (result.success) {
214
+ console.log(`Linear: Parent ${task.parentIssueId} โ†’ ${transitions.done}`);
215
+ }
216
+ }
217
+ break;
218
+ }
219
+ case "strict_review": {
220
+ // Mark task to dev team's testing status
221
+ if (devTestingStatus) {
222
+ const success = await updateIssueStatus(task.linearId, devTestingStatus, config, localConfig.team);
223
+ if (success) {
224
+ console.log(`Linear: ${task.id} โ†’ ${devTestingStatus}`);
225
+ }
226
+ // Also mark parent to testing if exists
227
+ if (task.parentIssueId && localConfig.qa_pm_teams?.length) {
228
+ const result = await updateParentToTesting(task.parentIssueId, localConfig.qa_pm_teams, config);
229
+ if (result.success) {
230
+ console.log(`Linear: Parent ${task.parentIssueId} โ†’ ${result.testingStatus}`);
231
+ }
232
+ }
233
+ }
234
+ else {
235
+ console.warn("No dev testing status configured, falling back to done");
236
+ const success = await updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
237
+ if (success) {
238
+ console.log(`Linear: ${task.id} โ†’ ${transitions.done}`);
239
+ }
240
+ }
241
+ break;
242
+ }
243
+ case "upstream_strict":
244
+ case "upstream_not_strict": {
245
+ // First, mark as done
246
+ const doneSuccess = await updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
247
+ if (doneSuccess) {
248
+ console.log(`Linear: ${task.id} โ†’ ${transitions.done}`);
249
+ }
250
+ // Try to update parent to testing
251
+ if (task.parentIssueId && localConfig.qa_pm_teams?.length) {
252
+ const result = await updateParentToTesting(task.parentIssueId, localConfig.qa_pm_teams, config);
253
+ parentUpdateSuccess = result.success;
254
+ parentTestingStatus = result.testingStatus;
255
+ if (parentUpdateSuccess) {
256
+ console.log(`Linear: Parent ${task.parentIssueId} โ†’ ${parentTestingStatus}`);
257
+ }
258
+ }
259
+ // Fallback logic for upstream_strict
260
+ if (completionMode === "upstream_strict" &&
261
+ !parentUpdateSuccess &&
262
+ devTestingStatus) {
263
+ // No parent or parent update failed, fallback to testing
264
+ const fallbackSuccess = await updateIssueStatus(task.linearId, devTestingStatus, config, localConfig.team);
265
+ if (fallbackSuccess) {
266
+ console.log(`Linear: ${task.id} โ†’ ${devTestingStatus} (fallback, no valid parent)`);
267
+ }
268
+ }
269
+ break;
270
+ }
117
271
  }
118
272
  // Add comment with commit info
119
273
  if (commit) {
@@ -123,43 +277,6 @@ Examples:
123
277
  console.log(`Linear: ๅทฒๆ–ฐๅขž commit ็•™่จ€`);
124
278
  }
125
279
  }
126
- // Update parent to Testing if exists
127
- if (task.parentIssueId && transitions.testing) {
128
- try {
129
- const client = getLinearClient();
130
- const searchResult = await client.searchIssues(task.parentIssueId);
131
- const parentIssue = searchResult.nodes.find((issue) => issue.identifier === task.parentIssueId);
132
- if (parentIssue) {
133
- const parentTeam = await parentIssue.team;
134
- if (parentTeam) {
135
- // Determine which team key to use for parent's workflow states
136
- // If qa_pm_team is configured and matches parent's team, use it
137
- // Otherwise, try to find the team key from config
138
- let parentTeamKey = localConfig.team;
139
- const teamEntries = Object.entries(config.teams);
140
- const matchingTeam = teamEntries.find(([_, t]) => t.id === parentTeam.id);
141
- if (matchingTeam) {
142
- parentTeamKey = matchingTeam[0];
143
- }
144
- else if (localConfig.qa_pm_team) {
145
- // Fallback to qa_pm_team if configured
146
- parentTeamKey = localConfig.qa_pm_team;
147
- }
148
- const parentStates = await getWorkflowStates(config, parentTeamKey);
149
- const testingState = parentStates.find((s) => s.name === transitions.testing);
150
- if (testingState) {
151
- await client.updateIssue(parentIssue.id, {
152
- stateId: testingState.id,
153
- });
154
- console.log(`Linear: Parent ${task.parentIssueId} โ†’ ${transitions.testing}`);
155
- }
156
- }
157
- }
158
- }
159
- catch (parentError) {
160
- console.error("Failed to update parent issue:", parentError);
161
- }
162
- }
163
280
  }
164
281
  else if (statusSource === "local") {
165
282
  console.log(`Local: ${task.id} marked as completed`);
@@ -187,9 +304,6 @@ Examples:
187
304
  if (aiMessage) {
188
305
  console.log(`AI: ${aiMessage}`);
189
306
  }
190
- if (task.parentIssueId && config.status_transitions?.testing) {
191
- console.log(`Parent: ${task.parentIssueId} โ†’ ${config.status_transitions.testing}`);
192
- }
193
307
  console.log(`\n๐ŸŽ‰ ไปปๅ‹™ๅฎŒๆˆ๏ผ`);
194
308
  }
195
309
  doneJob().catch(console.error);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { displayTaskFull } from "./lib/display.js";
2
+ import { fetchIssueDetail } from "./lib/sync.js";
3
+ import { loadCycleData } from "./utils.js";
4
+ async function getIssue() {
5
+ const args = process.argv.slice(2);
6
+ if (args.includes("--help") || args.includes("-h")) {
7
+ console.log(`Usage: ttt get-issue <issue-id> [--local]
8
+
9
+ Fetch and display issue details from Linear.
10
+
11
+ Arguments:
12
+ issue-id Issue ID (e.g., MP-624). Required.
13
+
14
+ Options:
15
+ --local Only show from local cycle data, don't fetch from Linear
16
+
17
+ Examples:
18
+ ttt get-issue MP-624 # Fetch from Linear and display
19
+ ttt get-issue MP-624 --local # Show from local data only`);
20
+ process.exit(0);
21
+ }
22
+ const localOnly = args.includes("--local");
23
+ const issueId = args.find((arg) => !arg.startsWith("-"));
24
+ if (!issueId) {
25
+ console.error("Issue ID is required.");
26
+ console.error("Usage: ttt get-issue <issue-id>");
27
+ process.exit(1);
28
+ }
29
+ // If local only, get from cycle data
30
+ if (localOnly) {
31
+ const data = await loadCycleData();
32
+ if (!data) {
33
+ console.error("No cycle data found. Run ttt sync first.");
34
+ process.exit(1);
35
+ }
36
+ const task = data.tasks.find((t) => t.id === issueId || t.id === `MP-${issueId}`);
37
+ if (!task) {
38
+ console.error(`Issue ${issueId} not found in local data.`);
39
+ process.exit(1);
40
+ }
41
+ displayTaskFull(task, "๐Ÿ“‹");
42
+ return;
43
+ }
44
+ // Fetch from Linear
45
+ console.log(`Fetching ${issueId} from Linear...`);
46
+ const task = await fetchIssueDetail(issueId);
47
+ if (!task) {
48
+ console.error(`Issue ${issueId} not found in Linear.`);
49
+ process.exit(1);
50
+ }
51
+ // Check local data for local status
52
+ const data = await loadCycleData();
53
+ if (data) {
54
+ const localTask = data.tasks.find((t) => t.id === issueId);
55
+ if (localTask) {
56
+ task.localStatus = localTask.localStatus;
57
+ }
58
+ }
59
+ displayTaskFull(task, "๐Ÿ“‹");
60
+ }
61
+ getIssue().catch(console.error);