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.
- package/README.md +59 -8
- package/README.zh-TW.md +111 -22
- package/dist/bin/cli.d.ts +2 -0
- package/dist/bin/cli.js +87 -61
- package/dist/scripts/config.d.ts +2 -0
- package/dist/scripts/config.js +271 -0
- package/dist/scripts/done-job.d.ts +1 -0
- package/dist/scripts/done-job.js +215 -186
- package/dist/scripts/init.d.ts +2 -0
- package/dist/scripts/init.js +457 -278
- package/dist/scripts/status.d.ts +2 -0
- package/dist/scripts/status.js +251 -0
- package/dist/scripts/sync.d.ts +1 -0
- package/dist/scripts/sync.js +228 -152
- package/dist/scripts/utils.d.ts +109 -0
- package/dist/scripts/utils.js +116 -14766
- package/dist/scripts/work-on.d.ts +1 -0
- package/dist/scripts/work-on.js +132 -122
- package/package.json +52 -48
- package/templates/claude-code-commands/done-job.md +45 -0
- package/templates/claude-code-commands/sync-linear.md +32 -0
- package/templates/claude-code-commands/work-on.md +41 -0
- package/dist/cli-6rkvcjaj.js +0 -4923
- package/dist/cli-pyanjjwn.js +0 -21
|
@@ -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 {};
|
package/dist/scripts/sync.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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);
|