team-toon-tack 1.0.11 → 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,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env bun
2
+ import { getLinearClient, getTeamId, loadConfig, loadCycleData, loadLocalConfig, saveCycleData, } from "./utils.js";
3
+ const PRIORITY_LABELS = {
4
+ 0: "⚪ None",
5
+ 1: "šŸ”“ Urgent",
6
+ 2: "🟠 High",
7
+ 3: "🟔 Medium",
8
+ 4: "🟢 Low",
9
+ };
10
+ const LOCAL_STATUS_ORDER = [
11
+ "pending",
12
+ "in-progress",
13
+ "completed",
14
+ "blocked-backend",
15
+ ];
16
+ function parseArgs(args) {
17
+ let issueId;
18
+ let setStatus;
19
+ for (let i = 0; i < args.length; i++) {
20
+ const arg = args[i];
21
+ if (arg === "--set" || arg === "-s") {
22
+ setStatus = args[++i];
23
+ }
24
+ else if (!arg.startsWith("-")) {
25
+ issueId = arg;
26
+ }
27
+ }
28
+ return { issueId, setStatus };
29
+ }
30
+ async function status() {
31
+ const args = process.argv.slice(2);
32
+ // Handle help flag
33
+ if (args.includes("--help") || args.includes("-h")) {
34
+ console.log(`Usage: ttt status [issue-id] [--set <status>]
35
+
36
+ Show or modify the status of an issue.
37
+
38
+ Arguments:
39
+ issue-id Issue ID (e.g., MP-624). If omitted, shows in-progress task.
40
+
41
+ Options:
42
+ -s, --set <status> Set local and Linear status. Values:
43
+ +1 Move to next status (pending → in-progress → completed)
44
+ -1 Move to previous status
45
+ +2 Skip two statuses forward
46
+ -2 Skip two statuses backward
47
+ pending Set to pending
48
+ in-progress Set to in-progress
49
+ completed Set to completed
50
+ blocked Set to blocked-backend
51
+ todo Set Linear to Todo status
52
+ done Set Linear to Done status
53
+
54
+ Examples:
55
+ ttt status # Show current in-progress task
56
+ ttt status MP-624 # Show status of specific issue
57
+ ttt status MP-624 --set +1 # Move to next status
58
+ ttt status MP-624 --set done # Mark as done
59
+ ttt status --set pending # Reset current task to pending`);
60
+ process.exit(0);
61
+ }
62
+ const { issueId: argIssueId, setStatus } = parseArgs(args);
63
+ const config = await loadConfig();
64
+ const localConfig = await loadLocalConfig();
65
+ const data = await loadCycleData();
66
+ if (!data) {
67
+ console.error("No cycle data found. Run ttt sync first.");
68
+ process.exit(1);
69
+ }
70
+ // Find task
71
+ let task;
72
+ let issueId = argIssueId;
73
+ if (!issueId) {
74
+ // Find current in-progress task
75
+ const inProgressTasks = data.tasks.filter((t) => t.localStatus === "in-progress");
76
+ if (inProgressTasks.length === 0) {
77
+ console.log("No in-progress task found.");
78
+ console.log("\nAll tasks:");
79
+ for (const t of data.tasks) {
80
+ const statusIcon = t.localStatus === "completed"
81
+ ? "āœ…"
82
+ : t.localStatus === "in-progress"
83
+ ? "šŸ”„"
84
+ : t.localStatus === "blocked-backend"
85
+ ? "🚫"
86
+ : "šŸ“‹";
87
+ console.log(` ${statusIcon} ${t.id}: ${t.title} [${t.localStatus}]`);
88
+ }
89
+ process.exit(0);
90
+ }
91
+ task = inProgressTasks[0];
92
+ issueId = task.id;
93
+ }
94
+ else {
95
+ task = data.tasks.find((t) => t.id === issueId || t.id === `MP-${issueId}`);
96
+ if (!task) {
97
+ console.error(`Issue ${issueId} not found in local data.`);
98
+ process.exit(1);
99
+ }
100
+ }
101
+ // If setting status
102
+ if (setStatus) {
103
+ const currentIndex = LOCAL_STATUS_ORDER.indexOf(task.localStatus);
104
+ let newLocalStatus;
105
+ let newLinearStatus;
106
+ // Parse status change
107
+ if (setStatus === "+1") {
108
+ const newIndex = Math.min(currentIndex + 1, LOCAL_STATUS_ORDER.length - 1);
109
+ newLocalStatus = LOCAL_STATUS_ORDER[newIndex];
110
+ }
111
+ else if (setStatus === "-1") {
112
+ const newIndex = Math.max(currentIndex - 1, 0);
113
+ newLocalStatus = LOCAL_STATUS_ORDER[newIndex];
114
+ }
115
+ else if (setStatus === "+2") {
116
+ const newIndex = Math.min(currentIndex + 2, LOCAL_STATUS_ORDER.length - 1);
117
+ newLocalStatus = LOCAL_STATUS_ORDER[newIndex];
118
+ }
119
+ else if (setStatus === "-2") {
120
+ const newIndex = Math.max(currentIndex - 2, 0);
121
+ newLocalStatus = LOCAL_STATUS_ORDER[newIndex];
122
+ }
123
+ else if ([
124
+ "pending",
125
+ "in-progress",
126
+ "completed",
127
+ "blocked-backend",
128
+ "blocked",
129
+ ].includes(setStatus)) {
130
+ newLocalStatus =
131
+ setStatus === "blocked"
132
+ ? "blocked-backend"
133
+ : setStatus;
134
+ }
135
+ else if (["todo", "in_progress", "done", "testing"].includes(setStatus)) {
136
+ // Map to Linear status
137
+ const statusTransitions = config.status_transitions || {
138
+ todo: "Todo",
139
+ in_progress: "In Progress",
140
+ done: "Done",
141
+ testing: "Testing",
142
+ };
143
+ newLinearStatus =
144
+ statusTransitions[setStatus];
145
+ // Also update local status accordingly
146
+ if (setStatus === "todo") {
147
+ newLocalStatus = "pending";
148
+ }
149
+ else if (setStatus === "in_progress") {
150
+ newLocalStatus = "in-progress";
151
+ }
152
+ else if (setStatus === "done") {
153
+ newLocalStatus = "completed";
154
+ }
155
+ }
156
+ else {
157
+ console.error(`Unknown status: ${setStatus}`);
158
+ process.exit(1);
159
+ }
160
+ // Update local status
161
+ if (newLocalStatus && newLocalStatus !== task.localStatus) {
162
+ const oldStatus = task.localStatus;
163
+ task.localStatus = newLocalStatus;
164
+ await saveCycleData(data);
165
+ console.log(`Local: ${task.id} ${oldStatus} → ${newLocalStatus}`);
166
+ }
167
+ // Update Linear status
168
+ if (newLinearStatus || newLocalStatus) {
169
+ try {
170
+ const client = getLinearClient();
171
+ const teamId = getTeamId(config, localConfig.team);
172
+ const workflowStates = await client.workflowStates({
173
+ filter: { team: { id: { eq: teamId } } },
174
+ });
175
+ let targetStateName = newLinearStatus;
176
+ if (!targetStateName && newLocalStatus) {
177
+ // Map local status to Linear status
178
+ const statusTransitions = config.status_transitions || {
179
+ todo: "Todo",
180
+ in_progress: "In Progress",
181
+ done: "Done",
182
+ };
183
+ if (newLocalStatus === "pending") {
184
+ targetStateName = statusTransitions.todo;
185
+ }
186
+ else if (newLocalStatus === "in-progress") {
187
+ targetStateName = statusTransitions.in_progress;
188
+ }
189
+ else if (newLocalStatus === "completed") {
190
+ targetStateName = statusTransitions.done;
191
+ }
192
+ }
193
+ if (targetStateName) {
194
+ const targetState = workflowStates.nodes.find((s) => s.name === targetStateName);
195
+ if (targetState) {
196
+ await client.updateIssue(task.linearId, {
197
+ stateId: targetState.id,
198
+ });
199
+ console.log(`Linear: ${task.id} → ${targetStateName}`);
200
+ }
201
+ }
202
+ }
203
+ catch (e) {
204
+ console.error("Failed to update Linear:", e);
205
+ }
206
+ }
207
+ }
208
+ // Display task info
209
+ console.log(`\n${"═".repeat(50)}`);
210
+ const statusIcon = task.localStatus === "completed"
211
+ ? "āœ…"
212
+ : task.localStatus === "in-progress"
213
+ ? "šŸ”„"
214
+ : task.localStatus === "blocked-backend"
215
+ ? "🚫"
216
+ : "šŸ“‹";
217
+ console.log(`${statusIcon} ${task.id}: ${task.title}`);
218
+ console.log(`${"═".repeat(50)}`);
219
+ console.log(`\nStatus:`);
220
+ console.log(` Local: ${task.localStatus}`);
221
+ console.log(` Linear: ${task.status}`);
222
+ console.log(`\nInfo:`);
223
+ console.log(` Priority: ${PRIORITY_LABELS[task.priority] || "None"}`);
224
+ console.log(` Labels: ${task.labels.join(", ")}`);
225
+ console.log(` Assignee: ${task.assignee || "Unassigned"}`);
226
+ console.log(` Branch: ${task.branch || "N/A"}`);
227
+ if (task.url)
228
+ console.log(` URL: ${task.url}`);
229
+ if (task.description) {
230
+ console.log(`\nšŸ“ Description:\n${task.description}`);
231
+ }
232
+ if (task.attachments && task.attachments.length > 0) {
233
+ console.log(`\nšŸ“Ž Attachments:`);
234
+ for (const att of task.attachments) {
235
+ console.log(` - ${att.title}: ${att.url}`);
236
+ }
237
+ }
238
+ if (task.comments && task.comments.length > 0) {
239
+ console.log(`\nšŸ’¬ Comments (${task.comments.length}):`);
240
+ for (const comment of task.comments) {
241
+ const date = new Date(comment.createdAt).toLocaleDateString();
242
+ console.log(`\n [${comment.user || "Unknown"} - ${date}]`);
243
+ const lines = comment.body.split("\n");
244
+ for (const line of lines) {
245
+ console.log(` ${line}`);
246
+ }
247
+ }
248
+ }
249
+ console.log(`\n${"─".repeat(50)}`);
250
+ }
251
+ status().catch(console.error);
@@ -0,0 +1 @@
1
+ export {};
@@ -1,171 +1,247 @@
1
- import {
2
- getLinearClient,
3
- getPrioritySortIndex,
4
- getTeamId,
5
- loadConfig,
6
- loadCycleData,
7
- loadLocalConfig,
8
- saveConfig,
9
- saveCycleData
10
- } from "./utils.js";
11
- import"../cli-pyanjjwn.js";
12
-
13
- // scripts/sync.ts
1
+ import { getLinearClient, getPrioritySortIndex, getTeamId, loadConfig, loadCycleData, loadLocalConfig, saveConfig, saveCycleData, } from "./utils.js";
14
2
  async function sync() {
15
- const args = process.argv.slice(2);
16
- if (args.includes("--help") || args.includes("-h")) {
17
- console.log(`Usage: ttt sync
3
+ const args = process.argv.slice(2);
4
+ // Handle help flag
5
+ if (args.includes("--help") || args.includes("-h")) {
6
+ console.log(`Usage: ttt sync [issue-id]
18
7
 
19
8
  Sync issues from Linear to local cycle.ttt file.
20
9
 
10
+ Arguments:
11
+ issue-id Optional. Sync only this specific issue (e.g., MP-624)
12
+
21
13
  What it does:
22
14
  - Fetches active cycle from Linear
23
- - Downloads all issues matching configured label
15
+ - Downloads all issues matching configured filters
24
16
  - Preserves local status for existing tasks
25
17
  - Updates config with new cycle info
26
18
 
27
19
  Examples:
28
- ttt sync # Sync in current directory
20
+ ttt sync # Sync all matching issues
21
+ ttt sync MP-624 # Sync only this specific issue
29
22
  ttt sync -d .ttt # Sync using .ttt directory`);
30
- process.exit(0);
31
- }
32
- const config = await loadConfig();
33
- const localConfig = await loadLocalConfig();
34
- const client = getLinearClient();
35
- const teamId = getTeamId(config, localConfig.team);
36
- const excludedEmails = new Set((localConfig.exclude_assignees ?? []).map((key) => config.users[key]?.email).filter(Boolean));
37
- console.log("Fetching latest cycle...");
38
- const team = await client.team(teamId);
39
- const activeCycle = await team.activeCycle;
40
- if (!activeCycle) {
41
- console.error("No active cycle found.");
42
- process.exit(1);
43
- }
44
- const cycleId = activeCycle.id;
45
- const cycleName = activeCycle.name ?? `Cycle #${activeCycle.number}`;
46
- const newCycleInfo = {
47
- id: cycleId,
48
- name: cycleName,
49
- start_date: activeCycle.startsAt?.toISOString().split("T")[0] ?? "",
50
- end_date: activeCycle.endsAt?.toISOString().split("T")[0] ?? ""
51
- };
52
- const existingData = await loadCycleData();
53
- const oldCycleId = config.current_cycle?.id ?? existingData?.cycleId;
54
- if (oldCycleId && oldCycleId !== cycleId) {
55
- const oldCycleName = config.current_cycle?.name ?? existingData?.cycleName ?? "Unknown";
56
- console.log(`Cycle changed: ${oldCycleName} → ${cycleName}`);
57
- if (config.current_cycle) {
58
- config.cycle_history = config.cycle_history ?? [];
59
- config.cycle_history = config.cycle_history.filter((c) => c.id !== config.current_cycle.id);
60
- config.cycle_history.unshift(config.current_cycle);
61
- if (config.cycle_history.length > 10) {
62
- config.cycle_history = config.cycle_history.slice(0, 10);
63
- }
23
+ process.exit(0);
64
24
  }
65
- config.current_cycle = newCycleInfo;
66
- await saveConfig(config);
67
- console.log("Config updated with new cycle (old cycle saved to history).");
68
- } else {
69
- if (!config.current_cycle || config.current_cycle.id !== cycleId) {
70
- config.current_cycle = newCycleInfo;
71
- await saveConfig(config);
25
+ // Parse issue ID argument (if provided)
26
+ const singleIssueId = args.find((arg) => !arg.startsWith("-") && arg.match(/^[A-Z]+-\d+$/i));
27
+ const config = await loadConfig();
28
+ const localConfig = await loadLocalConfig();
29
+ const client = getLinearClient();
30
+ const teamId = getTeamId(config, localConfig.team);
31
+ // Build excluded emails from local config
32
+ const excludedEmails = new Set((localConfig.exclude_assignees ?? [])
33
+ .map((key) => config.users[key]?.email)
34
+ .filter(Boolean));
35
+ // Build excluded labels set
36
+ const excludedLabels = new Set(localConfig.exclude_labels ?? []);
37
+ // Phase 1: Fetch active cycle directly from team
38
+ console.log("Fetching latest cycle...");
39
+ const team = await client.team(teamId);
40
+ const activeCycle = await team.activeCycle;
41
+ if (!activeCycle) {
42
+ console.error("No active cycle found.");
43
+ process.exit(1);
72
44
  }
73
- console.log(`Current cycle: ${cycleName}`);
74
- }
75
- const workflowStates = await client.workflowStates({
76
- filter: { team: { id: { eq: teamId } } }
77
- });
78
- const stateMap = new Map(workflowStates.nodes.map((s) => [s.name, s.id]));
79
- const testingStateId = stateMap.get("Testing");
80
- const existingTasksMap = new Map(existingData?.tasks.map((t) => [t.id, t]));
81
- const filterLabel = localConfig.label ?? "Frontend";
82
- console.log(`Fetching issues with label: ${filterLabel}...`);
83
- const issues = await client.issues({
84
- filter: {
85
- team: { id: { eq: teamId } },
86
- cycle: { id: { eq: cycleId } },
87
- labels: { name: { eq: filterLabel } },
88
- state: { name: { in: ["Todo", "In Progress"] } }
89
- },
90
- first: 50
91
- });
92
- if (issues.nodes.length === 0) {
93
- console.log(`No ${filterLabel} issues found in current cycle with Todo/In Progress status.`);
94
- }
95
- const tasks = [];
96
- let updatedCount = 0;
97
- for (const issue of issues.nodes) {
98
- const assignee = await issue.assignee;
99
- const assigneeEmail = assignee?.email;
100
- if (assigneeEmail && excludedEmails.has(assigneeEmail)) {
101
- continue;
45
+ const cycleId = activeCycle.id;
46
+ const cycleName = activeCycle.name ?? `Cycle #${activeCycle.number}`;
47
+ const newCycleInfo = {
48
+ id: cycleId,
49
+ name: cycleName,
50
+ start_date: activeCycle.startsAt?.toISOString().split("T")[0] ?? "",
51
+ end_date: activeCycle.endsAt?.toISOString().split("T")[0] ?? "",
52
+ };
53
+ // Check if cycle changed and update config with history
54
+ const existingData = await loadCycleData();
55
+ const oldCycleId = config.current_cycle?.id ?? existingData?.cycleId;
56
+ if (oldCycleId && oldCycleId !== cycleId) {
57
+ const oldCycleName = config.current_cycle?.name ?? existingData?.cycleName ?? "Unknown";
58
+ console.log(`Cycle changed: ${oldCycleName} → ${cycleName}`);
59
+ // Move old cycle to history (avoid duplicates)
60
+ if (config.current_cycle) {
61
+ config.cycle_history = config.cycle_history ?? [];
62
+ // Remove if already exists in history
63
+ config.cycle_history = config.cycle_history.filter((c) => c.id !== config.current_cycle?.id);
64
+ config.cycle_history.unshift(config.current_cycle);
65
+ // Keep only last 10 cycles
66
+ if (config.cycle_history.length > 10) {
67
+ config.cycle_history = config.cycle_history.slice(0, 10);
68
+ }
69
+ }
70
+ // Update current cycle
71
+ config.current_cycle = newCycleInfo;
72
+ await saveConfig(config);
73
+ console.log("Config updated with new cycle (old cycle saved to history).");
102
74
  }
103
- const labels = await issue.labels();
104
- const state = await issue.state;
105
- const parent = await issue.parent;
106
- const attachmentsData = await issue.attachments();
107
- const commentsData = await issue.comments();
108
- const attachments = attachmentsData.nodes.map((a) => ({
109
- id: a.id,
110
- title: a.title,
111
- url: a.url,
112
- sourceType: a.sourceType ?? undefined
113
- }));
114
- const comments = await Promise.all(commentsData.nodes.map(async (c) => {
115
- const user = await c.user;
116
- return {
117
- id: c.id,
118
- body: c.body,
119
- createdAt: c.createdAt.toISOString(),
120
- user: user?.displayName ?? user?.email
121
- };
122
- }));
123
- let localStatus = "pending";
124
- if (existingTasksMap.has(issue.identifier)) {
125
- const existing = existingTasksMap.get(issue.identifier);
126
- localStatus = existing.localStatus;
127
- if (localStatus === "completed" && state && testingStateId) {
128
- if (!["Testing", "Done", "In Review", "Canceled"].includes(state.name)) {
129
- console.log(`Updating ${issue.identifier} to Testing in Linear...`);
130
- await client.updateIssue(issue.id, { stateId: testingStateId });
131
- updatedCount++;
75
+ else {
76
+ // Update current cycle info even if ID unchanged (dates might change)
77
+ if (!config.current_cycle || config.current_cycle.id !== cycleId) {
78
+ config.current_cycle = newCycleInfo;
79
+ await saveConfig(config);
132
80
  }
133
- }
81
+ console.log(`Current cycle: ${cycleName}`);
134
82
  }
135
- const task = {
136
- id: issue.identifier,
137
- linearId: issue.id,
138
- title: issue.title,
139
- status: state ? state.name : "Unknown",
140
- localStatus,
141
- assignee: assigneeEmail,
142
- priority: issue.priority,
143
- labels: labels.nodes.map((l) => l.name),
144
- branch: issue.branchName,
145
- description: issue.description,
146
- parentIssueId: parent ? parent.identifier : undefined,
147
- url: issue.url,
148
- attachments: attachments.length > 0 ? attachments : undefined,
149
- comments: comments.length > 0 ? comments : undefined
83
+ // Phase 2: Fetch workflow states and get status mappings
84
+ const workflowStates = await client.workflowStates({
85
+ filter: { team: { id: { eq: teamId } } },
86
+ });
87
+ const stateMap = new Map(workflowStates.nodes.map((s) => [s.name, s.id]));
88
+ // Get status names from config or use defaults
89
+ const statusTransitions = config.status_transitions || {
90
+ todo: "Todo",
91
+ in_progress: "In Progress",
92
+ done: "Done",
93
+ testing: "Testing",
150
94
  };
151
- tasks.push(task);
152
- }
153
- tasks.sort((a, b) => {
154
- const pa = getPrioritySortIndex(a.priority, config.priority_order);
155
- const pb = getPrioritySortIndex(b.priority, config.priority_order);
156
- return pa - pb;
157
- });
158
- const newData = {
159
- cycleId,
160
- cycleName,
161
- updatedAt: new Date().toISOString(),
162
- tasks
163
- };
164
- await saveCycleData(newData);
165
- console.log(`
166
- āœ… Synced ${tasks.length} tasks for ${cycleName}.`);
167
- if (updatedCount > 0) {
168
- console.log(` Updated ${updatedCount} issues to Testing in Linear.`);
169
- }
95
+ const testingStateId = statusTransitions.testing
96
+ ? stateMap.get(statusTransitions.testing)
97
+ : undefined;
98
+ // Phase 3: Build existing tasks map for preserving local status
99
+ const existingTasksMap = new Map(existingData?.tasks.map((t) => [t.id, t]));
100
+ // Phase 4: Fetch current issues with full content
101
+ const filterLabel = localConfig.label;
102
+ const syncStatuses = [statusTransitions.todo, statusTransitions.in_progress];
103
+ let issues;
104
+ if (singleIssueId) {
105
+ // Sync single issue by ID
106
+ console.log(`Fetching issue ${singleIssueId}...`);
107
+ const searchResult = await client.searchIssues(singleIssueId);
108
+ const matchingIssue = searchResult.nodes.find((i) => i.identifier === singleIssueId);
109
+ if (!matchingIssue) {
110
+ console.error(`Issue ${singleIssueId} not found.`);
111
+ process.exit(1);
112
+ }
113
+ issues = { nodes: [matchingIssue] };
114
+ }
115
+ else {
116
+ // Sync all matching issues
117
+ console.log(`Fetching issues with status: ${syncStatuses.join(", ")}${filterLabel ? ` and label: ${filterLabel}` : ""}...`);
118
+ // Build filter - label is optional
119
+ const issueFilter = {
120
+ team: { id: { eq: teamId } },
121
+ cycle: { id: { eq: cycleId } },
122
+ state: { name: { in: syncStatuses } },
123
+ };
124
+ if (filterLabel) {
125
+ issueFilter.labels = { name: { eq: filterLabel } };
126
+ }
127
+ issues = await client.issues({
128
+ filter: issueFilter,
129
+ first: 50,
130
+ });
131
+ }
132
+ if (issues.nodes.length === 0) {
133
+ console.log(`No issues found in current cycle with ${syncStatuses.join("/")} status${filterLabel ? ` and label: ${filterLabel}` : ""}.`);
134
+ }
135
+ const tasks = [];
136
+ let updatedCount = 0;
137
+ for (const issueNode of issues.nodes) {
138
+ // Fetch full issue to get all relations (searchIssues returns IssueSearchResult which lacks some methods)
139
+ const issue = await client.issue(issueNode.id);
140
+ const assignee = await issue.assignee;
141
+ const assigneeEmail = assignee?.email;
142
+ // Skip excluded assignees
143
+ if (assigneeEmail && excludedEmails.has(assigneeEmail)) {
144
+ continue;
145
+ }
146
+ const labels = await issue.labels();
147
+ const labelNames = labels.nodes.map((l) => l.name);
148
+ // Skip if any label is in excluded list
149
+ if (labelNames.some((name) => excludedLabels.has(name))) {
150
+ continue;
151
+ }
152
+ const state = await issue.state;
153
+ const parent = await issue.parent;
154
+ const attachmentsData = await issue.attachments();
155
+ const commentsData = await issue.comments();
156
+ // Build attachments list
157
+ const attachments = attachmentsData.nodes.map((a) => ({
158
+ id: a.id,
159
+ title: a.title,
160
+ url: a.url,
161
+ sourceType: a.sourceType ?? undefined,
162
+ }));
163
+ // Build comments list
164
+ const comments = await Promise.all(commentsData.nodes.map(async (c) => {
165
+ const user = await c.user;
166
+ return {
167
+ id: c.id,
168
+ body: c.body,
169
+ createdAt: c.createdAt.toISOString(),
170
+ user: user?.displayName ?? user?.email,
171
+ };
172
+ }));
173
+ let localStatus = "pending";
174
+ // Preserve local status & sync completed tasks to Linear
175
+ if (existingTasksMap.has(issue.identifier)) {
176
+ const existing = existingTasksMap.get(issue.identifier);
177
+ localStatus = existing?.localStatus ?? "pending";
178
+ if (localStatus === "completed" && state && testingStateId) {
179
+ // Skip if already in terminal states (done, testing, or cancelled type)
180
+ const terminalStates = [statusTransitions.done];
181
+ if (statusTransitions.testing)
182
+ terminalStates.push(statusTransitions.testing);
183
+ const isTerminal = terminalStates.includes(state.name) || state.type === "cancelled";
184
+ if (!isTerminal) {
185
+ console.log(`Updating ${issue.identifier} to ${statusTransitions.testing} in Linear...`);
186
+ await client.updateIssue(issue.id, { stateId: testingStateId });
187
+ updatedCount++;
188
+ }
189
+ }
190
+ }
191
+ const task = {
192
+ id: issue.identifier,
193
+ linearId: issue.id,
194
+ title: issue.title,
195
+ status: state ? state.name : "Unknown",
196
+ localStatus: localStatus,
197
+ assignee: assigneeEmail,
198
+ priority: issue.priority,
199
+ labels: labelNames,
200
+ branch: issue.branchName,
201
+ description: issue.description ?? undefined,
202
+ parentIssueId: parent ? parent.identifier : undefined,
203
+ url: issue.url,
204
+ attachments: attachments.length > 0 ? attachments : undefined,
205
+ comments: comments.length > 0 ? comments : undefined,
206
+ };
207
+ tasks.push(task);
208
+ }
209
+ // Sort by priority using config order
210
+ tasks.sort((a, b) => {
211
+ const pa = getPrioritySortIndex(a.priority, config.priority_order);
212
+ const pb = getPrioritySortIndex(b.priority, config.priority_order);
213
+ return pa - pb;
214
+ });
215
+ let finalTasks;
216
+ if (singleIssueId && existingData) {
217
+ // Merge single issue into existing tasks
218
+ const existingTasks = existingData.tasks.filter((t) => t.id !== singleIssueId);
219
+ finalTasks = [...existingTasks, ...tasks];
220
+ // Re-sort after merge
221
+ finalTasks.sort((a, b) => {
222
+ const pa = getPrioritySortIndex(a.priority, config.priority_order);
223
+ const pb = getPrioritySortIndex(b.priority, config.priority_order);
224
+ return pa - pb;
225
+ });
226
+ }
227
+ else {
228
+ finalTasks = tasks;
229
+ }
230
+ const newData = {
231
+ cycleId: cycleId,
232
+ cycleName: cycleName,
233
+ updatedAt: new Date().toISOString(),
234
+ tasks: finalTasks,
235
+ };
236
+ await saveCycleData(newData);
237
+ if (singleIssueId) {
238
+ console.log(`\nāœ… Synced issue ${singleIssueId}.`);
239
+ }
240
+ else {
241
+ console.log(`\nāœ… Synced ${tasks.length} tasks for ${cycleName}.`);
242
+ }
243
+ if (updatedCount > 0) {
244
+ console.log(` Updated ${updatedCount} issues to ${statusTransitions.testing} in Linear.`);
245
+ }
170
246
  }
171
247
  sync().catch(console.error);