team-toon-tack 1.6.0 → 1.6.2

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.
@@ -2,6 +2,7 @@
2
2
  import fs from "node:fs/promises";
3
3
  import { decode, encode } from "@toon-format/toon";
4
4
  import prompts from "prompts";
5
+ import { buildConfig, buildLocalConfig, findTeamKey, findUserKey, getDefaultStatusTransitions, } from "./lib/config-builder.js";
5
6
  import { fileExists, getLinearClient, getPaths, } from "./utils.js";
6
7
  function parseArgs(args) {
7
8
  const options = { interactive: true };
@@ -62,38 +63,7 @@ EXAMPLES:
62
63
  bun run init -k lin_api_xxx -y
63
64
  `);
64
65
  }
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
66
+ async function promptForApiKey(options) {
97
67
  let apiKey = options.apiKey || process.env.LINEAR_API_KEY;
98
68
  if (!apiKey && options.interactive) {
99
69
  const response = await prompts({
@@ -106,22 +76,9 @@ async function init() {
106
76
  });
107
77
  apiKey = response.apiKey;
108
78
  }
109
- if (!apiKey) {
110
- console.error("Error: LINEAR_API_KEY is required.");
111
- console.error("Get your API key from: https://linear.app/settings/api");
112
- process.exit(1);
113
- }
114
- // Create Linear client
115
- const client = getLinearClient();
116
- console.log("\nšŸ“” Fetching data from Linear...");
117
- // Fetch teams
118
- const teamsData = await client.teams();
119
- const teams = teamsData.nodes;
120
- if (teams.length === 0) {
121
- console.error("Error: No teams found in your Linear workspace.");
122
- process.exit(1);
123
- }
124
- // Select teams (multi-select)
79
+ return apiKey;
80
+ }
81
+ async function selectTeams(teams, options) {
125
82
  let selectedTeams = [teams[0]];
126
83
  let primaryTeam = teams[0];
127
84
  if (options.team) {
@@ -141,7 +98,6 @@ async function init() {
141
98
  });
142
99
  if (response.teamIds && response.teamIds.length > 0) {
143
100
  selectedTeams = teams.filter((t) => response.teamIds.includes(t.id));
144
- // If multiple teams selected, ask for primary team
145
101
  if (selectedTeams.length > 1) {
146
102
  const primaryResponse = await prompts({
147
103
  type: "select",
@@ -158,30 +114,9 @@ async function init() {
158
114
  }
159
115
  }
160
116
  }
161
- console.log(` Teams: ${selectedTeams.map((t) => t.name).join(", ")}`);
162
- if (selectedTeams.length > 1) {
163
- console.log(` Primary: ${primaryTeam.name}`);
164
- }
165
- // Use primary team for fetching members, labels, states (they may differ per team)
166
- const selectedTeam = primaryTeam;
167
- // Fetch team members
168
- const members = await selectedTeam.members();
169
- const users = members.nodes;
170
- console.log(` Users: ${users.length}`);
171
- // Fetch labels
172
- const labelsData = await client.issueLabels({
173
- filter: { team: { id: { eq: selectedTeam.id } } },
174
- });
175
- const labels = labelsData.nodes;
176
- console.log(` Labels: ${labels.length}`);
177
- // Fetch workflow states
178
- const statesData = await client.workflowStates({
179
- filter: { team: { id: { eq: selectedTeam.id } } },
180
- });
181
- const states = statesData.nodes;
182
- // Fetch current cycle using activeCycle (direct and accurate)
183
- const currentCycle = await selectedTeam.activeCycle;
184
- // Select current user
117
+ return { selected: selectedTeams, primary: primaryTeam };
118
+ }
119
+ async function selectUser(users, options) {
185
120
  let currentUser = users[0];
186
121
  if (options.user) {
187
122
  const found = users.find((u) => u.email?.toLowerCase() === options.user?.toLowerCase() ||
@@ -201,12 +136,13 @@ async function init() {
201
136
  });
202
137
  currentUser = users.find((u) => u.id === response.userId) || users[0];
203
138
  }
204
- // Select default label filter (optional)
205
- let defaultLabel;
139
+ return currentUser;
140
+ }
141
+ async function selectLabelFilter(labels, options) {
206
142
  if (options.label) {
207
- defaultLabel = options.label;
143
+ return options.label;
208
144
  }
209
- else if (options.interactive && labels.length > 0) {
145
+ if (options.interactive && labels.length > 0) {
210
146
  const labelChoices = [
211
147
  { title: "(No filter - sync all labels)", value: undefined },
212
148
  ...labels.map((l) => ({ title: l.name, value: l.name })),
@@ -217,210 +153,157 @@ async function init() {
217
153
  message: "Select label filter (optional):",
218
154
  choices: labelChoices,
219
155
  });
220
- defaultLabel = response.label;
156
+ return response.label;
221
157
  }
222
- // Select excluded labels (optional)
223
- let excludeLabels = [];
224
- if (options.interactive && labels.length > 0) {
225
- const excludeLabelsResponse = await prompts({
226
- type: "multiselect",
227
- name: "excludeLabels",
228
- message: "Select labels to exclude (optional, space to select):",
229
- choices: labels.map((l) => ({ title: l.name, value: l.name })),
230
- });
231
- excludeLabels = excludeLabelsResponse.excludeLabels || [];
232
- }
233
- // Select excluded users (optional)
234
- let excludeAssignees = [];
235
- if (options.interactive && users.length > 0) {
236
- const excludeUsersResponse = await prompts({
237
- type: "multiselect",
238
- name: "excludeUsers",
239
- message: "Select users to exclude (optional, space to select):",
240
- choices: users
241
- .filter((u) => u.id !== currentUser.id) // Don't show current user
242
- .map((u) => ({
243
- title: `${u.displayName || u.name} (${u.email})`,
244
- value: (u.displayName || u.name || u.email?.split("@")[0] || "user")
245
- .toLowerCase()
246
- .replace(/[^a-z0-9]/g, "_"),
247
- })),
248
- });
249
- excludeAssignees = excludeUsersResponse.excludeUsers || [];
158
+ return undefined;
159
+ }
160
+ async function selectStatusMappings(states, options) {
161
+ const defaults = getDefaultStatusTransitions(states);
162
+ if (!options.interactive || states.length === 0) {
163
+ return defaults;
250
164
  }
251
- // Select status mappings
165
+ console.log("\nšŸ“Š Configure status mappings:");
252
166
  const stateChoices = states.map((s) => ({
253
167
  title: `${s.name} (${s.type})`,
254
168
  value: s.name,
255
169
  }));
256
- // Find default states by type
257
- const defaultTodo = states.find((s) => s.type === "unstarted")?.name ||
258
- states.find((s) => s.name === "Todo")?.name ||
259
- states[0]?.name ||
260
- "Todo";
261
- const defaultInProgress = states.find((s) => s.type === "started")?.name ||
262
- states.find((s) => s.name === "In Progress")?.name ||
263
- "In Progress";
264
- const defaultDone = states.find((s) => s.type === "completed")?.name ||
265
- states.find((s) => s.name === "Done")?.name ||
266
- "Done";
267
- const defaultTesting = states.find((s) => s.name === "Testing")?.name ||
268
- states.find((s) => s.name === "In Review")?.name;
269
- let statusTransitions = {
270
- todo: defaultTodo,
271
- in_progress: defaultInProgress,
272
- done: defaultDone,
273
- testing: defaultTesting,
170
+ const todoResponse = await prompts({
171
+ type: "select",
172
+ name: "todo",
173
+ message: 'Select status for "Todo" (pending tasks):',
174
+ choices: stateChoices,
175
+ initial: stateChoices.findIndex((c) => c.value === defaults.todo),
176
+ });
177
+ const inProgressResponse = await prompts({
178
+ type: "select",
179
+ name: "in_progress",
180
+ message: 'Select status for "In Progress" (working tasks):',
181
+ choices: stateChoices,
182
+ initial: stateChoices.findIndex((c) => c.value === defaults.in_progress),
183
+ });
184
+ const doneResponse = await prompts({
185
+ type: "select",
186
+ name: "done",
187
+ message: 'Select status for "Done" (completed tasks):',
188
+ choices: stateChoices,
189
+ initial: stateChoices.findIndex((c) => c.value === defaults.done),
190
+ });
191
+ const testingChoices = [
192
+ { title: "(None)", value: undefined },
193
+ ...stateChoices,
194
+ ];
195
+ const testingResponse = await prompts({
196
+ type: "select",
197
+ name: "testing",
198
+ message: 'Select status for "Testing" (optional, for parent tasks):',
199
+ choices: testingChoices,
200
+ initial: defaults.testing
201
+ ? testingChoices.findIndex((c) => c.value === defaults.testing)
202
+ : 0,
203
+ });
204
+ return {
205
+ todo: todoResponse.todo || defaults.todo,
206
+ in_progress: inProgressResponse.in_progress || defaults.in_progress,
207
+ done: doneResponse.done || defaults.done,
208
+ testing: testingResponse.testing,
274
209
  };
275
- if (options.interactive && states.length > 0) {
276
- console.log("\nšŸ“Š Configure status mappings:");
277
- const todoResponse = await prompts({
278
- type: "select",
279
- name: "todo",
280
- message: 'Select status for "Todo" (pending tasks):',
281
- choices: stateChoices,
282
- initial: stateChoices.findIndex((c) => c.value === defaultTodo),
283
- });
284
- const inProgressResponse = await prompts({
285
- type: "select",
286
- name: "in_progress",
287
- message: 'Select status for "In Progress" (working tasks):',
288
- choices: stateChoices,
289
- initial: stateChoices.findIndex((c) => c.value === defaultInProgress),
290
- });
291
- const doneResponse = await prompts({
292
- type: "select",
293
- name: "done",
294
- message: 'Select status for "Done" (completed tasks):',
295
- choices: stateChoices,
296
- initial: stateChoices.findIndex((c) => c.value === defaultDone),
297
- });
298
- // Testing is optional
299
- const testingChoices = [
300
- { title: "(None)", value: undefined },
301
- ...stateChoices,
302
- ];
303
- const testingResponse = await prompts({
304
- type: "select",
305
- name: "testing",
306
- message: 'Select status for "Testing" (optional, for parent tasks):',
307
- choices: testingChoices,
308
- initial: defaultTesting
309
- ? testingChoices.findIndex((c) => c.value === defaultTesting)
310
- : 0,
311
- });
312
- statusTransitions = {
313
- todo: todoResponse.todo || defaultTodo,
314
- in_progress: inProgressResponse.in_progress || defaultInProgress,
315
- done: doneResponse.done || defaultDone,
316
- testing: testingResponse.testing,
317
- };
318
- }
319
- // Build config
320
- const teamsConfig = {};
321
- for (const team of teams) {
322
- const key = team.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
323
- teamsConfig[key] = {
324
- id: team.id,
325
- name: team.name,
326
- icon: team.icon || undefined,
327
- };
210
+ }
211
+ async function init() {
212
+ const args = process.argv.slice(2);
213
+ const options = parseArgs(args);
214
+ const paths = getPaths();
215
+ console.log("šŸš€ Linear-TOON Initialization\n");
216
+ // Check existing files
217
+ const configExists = await fileExists(paths.configPath);
218
+ const localExists = await fileExists(paths.localPath);
219
+ if ((configExists || localExists) && !options.force) {
220
+ console.log("Existing configuration found:");
221
+ if (configExists)
222
+ console.log(` āœ“ ${paths.configPath}`);
223
+ if (localExists)
224
+ console.log(` āœ“ ${paths.localPath}`);
225
+ if (options.interactive) {
226
+ const { proceed } = await prompts({
227
+ type: "confirm",
228
+ name: "proceed",
229
+ message: "Update existing configuration?",
230
+ initial: true,
231
+ });
232
+ if (!proceed) {
233
+ console.log("Cancelled.");
234
+ process.exit(0);
235
+ }
236
+ }
237
+ else {
238
+ console.log("Use --force to overwrite existing files.");
239
+ process.exit(1);
240
+ }
328
241
  }
329
- const usersConfig = {};
330
- for (const user of users) {
331
- const key = (user.displayName ||
332
- user.name ||
333
- user.email?.split("@")[0] ||
334
- "user")
335
- .toLowerCase()
336
- .replace(/[^a-z0-9]/g, "_");
337
- usersConfig[key] = {
338
- id: user.id,
339
- email: user.email || "",
340
- displayName: user.displayName || user.name || "",
341
- };
242
+ // Get API key
243
+ const apiKey = await promptForApiKey(options);
244
+ if (!apiKey) {
245
+ console.error("Error: LINEAR_API_KEY is required.");
246
+ console.error("Get your API key from: https://linear.app/settings/api");
247
+ process.exit(1);
342
248
  }
343
- const labelsConfig = {};
344
- for (const label of labels) {
345
- const key = label.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
346
- labelsConfig[key] = {
347
- id: label.id,
348
- name: label.name,
349
- color: label.color || undefined,
350
- };
249
+ // Create Linear client
250
+ const client = getLinearClient();
251
+ console.log("\nšŸ“” Fetching data from Linear...");
252
+ // Fetch teams
253
+ const teamsData = await client.teams();
254
+ const teams = teamsData.nodes;
255
+ if (teams.length === 0) {
256
+ console.error("Error: No teams found in your Linear workspace.");
257
+ process.exit(1);
351
258
  }
352
- const statusesConfig = {};
353
- for (const state of states) {
354
- const key = state.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
355
- statusesConfig[key] = {
356
- name: state.name,
357
- type: state.type,
358
- };
259
+ // Select teams
260
+ const { selected: selectedTeams, primary: primaryTeam } = await selectTeams(teams, options);
261
+ console.log(` Teams: ${selectedTeams.map((t) => t.name).join(", ")}`);
262
+ if (selectedTeams.length > 1) {
263
+ console.log(` Primary: ${primaryTeam.name}`);
359
264
  }
360
- const config = {
361
- teams: teamsConfig,
362
- users: usersConfig,
363
- labels: labelsConfig,
364
- priorities: {
365
- urgent: { value: 1, name: "Urgent" },
366
- high: { value: 2, name: "High" },
367
- medium: { value: 3, name: "Medium" },
368
- low: { value: 4, name: "Low" },
369
- },
370
- statuses: statusesConfig,
371
- status_transitions: statusTransitions,
372
- priority_order: ["urgent", "high", "medium", "low", "none"],
373
- current_cycle: currentCycle
374
- ? {
375
- id: currentCycle.id,
376
- name: currentCycle.name || `Cycle #${currentCycle.number}`,
377
- start_date: currentCycle.startsAt?.toISOString().split("T")[0] || "",
378
- end_date: currentCycle.endsAt?.toISOString().split("T")[0] || "",
379
- }
380
- : undefined,
381
- cycle_history: [],
382
- };
383
- // Find current user key
384
- const currentUserKey = Object.entries(usersConfig).find(([_, u]) => u.id === currentUser.id)?.[0] || "user";
385
- // Find selected team keys
386
- const selectedTeamKey = Object.entries(teamsConfig).find(([_, t]) => t.id === primaryTeam.id)?.[0] || Object.keys(teamsConfig)[0];
265
+ // Fetch data from primary team
266
+ const selectedTeam = await client.team(primaryTeam.id);
267
+ const members = await selectedTeam.members();
268
+ const users = members.nodes;
269
+ console.log(` Users: ${users.length}`);
270
+ const labelsData = await client.issueLabels({
271
+ filter: { team: { id: { eq: primaryTeam.id } } },
272
+ });
273
+ const labels = labelsData.nodes;
274
+ console.log(` Labels: ${labels.length}`);
275
+ const statesData = await client.workflowStates({
276
+ filter: { team: { id: { eq: primaryTeam.id } } },
277
+ });
278
+ const states = statesData.nodes;
279
+ const currentCycle = (await selectedTeam.activeCycle);
280
+ // User selections
281
+ const currentUser = await selectUser(users, options);
282
+ const defaultLabel = await selectLabelFilter(labels, options);
283
+ const statusTransitions = await selectStatusMappings(states, options);
284
+ // Build config
285
+ const config = buildConfig(teams, users, labels, states, statusTransitions, currentCycle ?? undefined);
286
+ // Find keys
287
+ const currentUserKey = findUserKey(config.users, currentUser.id);
288
+ const primaryTeamKey = findTeamKey(config.teams, primaryTeam.id);
387
289
  const selectedTeamKeys = selectedTeams
388
- .map((team) => Object.entries(teamsConfig).find(([_, t]) => t.id === team.id)?.[0])
290
+ .map((team) => findTeamKey(config.teams, team.id))
389
291
  .filter((key) => key !== undefined);
390
- const localConfig = {
391
- current_user: currentUserKey,
392
- team: selectedTeamKey,
393
- teams: selectedTeamKeys.length > 1 ? selectedTeamKeys : undefined,
394
- label: defaultLabel,
395
- exclude_labels: excludeLabels.length > 0 ? excludeLabels : undefined,
396
- exclude_assignees: excludeAssignees.length > 0 ? excludeAssignees : undefined,
397
- };
292
+ const localConfig = buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel);
398
293
  // Write config files
399
294
  console.log("\nšŸ“ Writing configuration files...");
400
- // Ensure directory exists
401
295
  await fs.mkdir(paths.baseDir, { recursive: true });
402
296
  // Merge with existing config if exists
403
297
  if (configExists && !options.force) {
404
298
  try {
405
299
  const existingContent = await fs.readFile(paths.configPath, "utf-8");
406
300
  const existingConfig = decode(existingContent);
407
- // Merge: preserve existing custom fields
408
- config.status_transitions = {
409
- todo: "Todo",
410
- in_progress: "In Progress",
411
- done: "Done",
412
- ...existingConfig.status_transitions,
413
- ...config.status_transitions,
414
- };
415
- // Preserve cycle history
416
301
  if (existingConfig.cycle_history) {
417
302
  config.cycle_history = existingConfig.cycle_history;
418
303
  }
419
- // Preserve current_cycle if not fetched fresh
420
304
  if (!currentCycle && existingConfig.current_cycle) {
421
305
  config.current_cycle = existingConfig.current_cycle;
422
306
  }
423
- // Preserve priority_order if exists
424
307
  if (existingConfig.priority_order) {
425
308
  config.priority_order = existingConfig.priority_order;
426
309
  }
@@ -436,7 +319,6 @@ async function init() {
436
319
  try {
437
320
  const existingContent = await fs.readFile(paths.localPath, "utf-8");
438
321
  const existingLocal = decode(existingContent);
439
- // Preserve existing values only if not newly set
440
322
  if (!options.interactive) {
441
323
  if (existingLocal.current_user)
442
324
  localConfig.current_user = existingLocal.current_user;
@@ -446,8 +328,6 @@ async function init() {
446
328
  localConfig.teams = existingLocal.teams;
447
329
  if (existingLocal.label)
448
330
  localConfig.label = existingLocal.label;
449
- if (existingLocal.exclude_assignees)
450
- localConfig.exclude_assignees = existingLocal.exclude_assignees;
451
331
  if (existingLocal.exclude_labels)
452
332
  localConfig.exclude_labels = existingLocal.exclude_labels;
453
333
  }
@@ -467,12 +347,7 @@ async function init() {
467
347
  }
468
348
  console.log(` User: ${currentUser.displayName || currentUser.name} (${currentUser.email})`);
469
349
  console.log(` Label filter: ${defaultLabel || "(none)"}`);
470
- if (excludeLabels.length > 0) {
471
- console.log(` Excluded labels: ${excludeLabels.join(", ")}`);
472
- }
473
- if (excludeAssignees.length > 0) {
474
- console.log(` Excluded users: ${excludeAssignees.join(", ")}`);
475
- }
350
+ console.log(` (Use 'ttt config filters' to set excluded labels/users)`);
476
351
  if (currentCycle) {
477
352
  console.log(` Cycle: ${currentCycle.name || `Cycle #${currentCycle.number}`}`);
478
353
  }
@@ -0,0 +1,41 @@
1
+ import type { Config, LocalConfig, StatusTransitions, TeamConfig, UserConfig, LabelConfig } from "../utils.js";
2
+ export interface LinearTeam {
3
+ id: string;
4
+ name: string;
5
+ icon?: string | null;
6
+ }
7
+ export interface LinearUser {
8
+ id: string;
9
+ email?: string | null;
10
+ displayName?: string | null;
11
+ name?: string | null;
12
+ }
13
+ export interface LinearLabel {
14
+ id: string;
15
+ name: string;
16
+ color?: string | null;
17
+ }
18
+ export interface LinearState {
19
+ id: string;
20
+ name: string;
21
+ type: string;
22
+ }
23
+ export interface LinearCycle {
24
+ id: string;
25
+ name?: string | null;
26
+ number: number;
27
+ startsAt?: Date | null;
28
+ endsAt?: Date | null;
29
+ }
30
+ export declare function buildTeamsConfig(teams: LinearTeam[]): Record<string, TeamConfig>;
31
+ export declare function buildUsersConfig(users: LinearUser[]): Record<string, UserConfig>;
32
+ export declare function buildLabelsConfig(labels: LinearLabel[]): Record<string, LabelConfig>;
33
+ export declare function buildStatusesConfig(states: LinearState[]): Record<string, {
34
+ name: string;
35
+ type: string;
36
+ }>;
37
+ export declare function getDefaultStatusTransitions(states: LinearState[]): StatusTransitions;
38
+ export declare function buildConfig(teams: LinearTeam[], users: LinearUser[], labels: LinearLabel[], states: LinearState[], statusTransitions: StatusTransitions, currentCycle?: LinearCycle): Config;
39
+ export declare function findUserKey(usersConfig: Record<string, UserConfig>, userId: string): string;
40
+ export declare function findTeamKey(teamsConfig: Record<string, TeamConfig>, teamId: string): string;
41
+ export declare function buildLocalConfig(currentUserKey: string, primaryTeamKey: string, selectedTeamKeys: string[], defaultLabel?: string, excludeLabels?: string[]): LocalConfig;
@@ -0,0 +1,118 @@
1
+ export function buildTeamsConfig(teams) {
2
+ const config = {};
3
+ for (const team of teams) {
4
+ const key = team.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
5
+ config[key] = {
6
+ id: team.id,
7
+ name: team.name,
8
+ icon: team.icon || undefined,
9
+ };
10
+ }
11
+ return config;
12
+ }
13
+ export function buildUsersConfig(users) {
14
+ const config = {};
15
+ for (const user of users) {
16
+ const key = (user.displayName ||
17
+ user.name ||
18
+ user.email?.split("@")[0] ||
19
+ "user")
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9]/g, "_");
22
+ config[key] = {
23
+ id: user.id,
24
+ email: user.email || "",
25
+ displayName: user.displayName || user.name || "",
26
+ };
27
+ }
28
+ return config;
29
+ }
30
+ export function buildLabelsConfig(labels) {
31
+ const config = {};
32
+ for (const label of labels) {
33
+ const key = label.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
34
+ config[key] = {
35
+ id: label.id,
36
+ name: label.name,
37
+ color: label.color || undefined,
38
+ };
39
+ }
40
+ return config;
41
+ }
42
+ export function buildStatusesConfig(states) {
43
+ const config = {};
44
+ for (const state of states) {
45
+ const key = state.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
46
+ config[key] = {
47
+ name: state.name,
48
+ type: state.type,
49
+ };
50
+ }
51
+ return config;
52
+ }
53
+ // Helper to find status by keyword (case insensitive)
54
+ function findStatusByKeyword(states, keywords) {
55
+ const lowerKeywords = keywords.map((k) => k.toLowerCase());
56
+ return states.find((s) => lowerKeywords.some((k) => s.name.toLowerCase().includes(k)))?.name;
57
+ }
58
+ export function getDefaultStatusTransitions(states) {
59
+ const defaultTodo = states.find((s) => s.type === "unstarted")?.name ||
60
+ findStatusByKeyword(states, ["todo", "pending"]) ||
61
+ states[0]?.name ||
62
+ "Todo";
63
+ const defaultInProgress = states.find((s) => s.type === "started")?.name ||
64
+ findStatusByKeyword(states, ["in progress", "progress"]) ||
65
+ "In Progress";
66
+ const defaultDone = states.find((s) => s.type === "completed")?.name ||
67
+ findStatusByKeyword(states, ["done", "complete"]) ||
68
+ "Done";
69
+ const defaultTesting = findStatusByKeyword(states, ["testing", "review"]) ||
70
+ undefined;
71
+ return {
72
+ todo: defaultTodo,
73
+ in_progress: defaultInProgress,
74
+ done: defaultDone,
75
+ testing: defaultTesting,
76
+ };
77
+ }
78
+ export function buildConfig(teams, users, labels, states, statusTransitions, currentCycle) {
79
+ return {
80
+ teams: buildTeamsConfig(teams),
81
+ users: buildUsersConfig(users),
82
+ labels: buildLabelsConfig(labels),
83
+ priorities: {
84
+ urgent: { value: 1, name: "Urgent" },
85
+ high: { value: 2, name: "High" },
86
+ medium: { value: 3, name: "Medium" },
87
+ low: { value: 4, name: "Low" },
88
+ },
89
+ statuses: buildStatusesConfig(states),
90
+ status_transitions: statusTransitions,
91
+ priority_order: ["urgent", "high", "medium", "low", "none"],
92
+ current_cycle: currentCycle
93
+ ? {
94
+ id: currentCycle.id,
95
+ name: currentCycle.name || `Cycle #${currentCycle.number}`,
96
+ start_date: currentCycle.startsAt?.toISOString().split("T")[0] || "",
97
+ end_date: currentCycle.endsAt?.toISOString().split("T")[0] || "",
98
+ }
99
+ : undefined,
100
+ cycle_history: [],
101
+ };
102
+ }
103
+ export function findUserKey(usersConfig, userId) {
104
+ return (Object.entries(usersConfig).find(([_, u]) => u.id === userId)?.[0] || "user");
105
+ }
106
+ export function findTeamKey(teamsConfig, teamId) {
107
+ return (Object.entries(teamsConfig).find(([_, t]) => t.id === teamId)?.[0] ||
108
+ Object.keys(teamsConfig)[0]);
109
+ }
110
+ export function buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, excludeLabels) {
111
+ return {
112
+ current_user: currentUserKey,
113
+ team: primaryTeamKey,
114
+ teams: selectedTeamKeys.length > 1 ? selectedTeamKeys : undefined,
115
+ label: defaultLabel,
116
+ exclude_labels: excludeLabels && excludeLabels.length > 0 ? excludeLabels : undefined,
117
+ };
118
+ }