team-toon-tack 1.0.12 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -8
- package/README.zh-TW.md +111 -22
- package/{bin → dist/bin}/cli.js +41 -19
- package/dist/scripts/config.js +271 -0
- package/{scripts → dist/scripts}/done-job.js +79 -62
- package/dist/scripts/init.d.ts +2 -0
- package/dist/scripts/init.js +492 -0
- package/dist/scripts/status.d.ts +2 -0
- package/dist/scripts/status.js +251 -0
- package/dist/scripts/sync.js +247 -0
- package/{scripts → dist/scripts}/utils.d.ts +11 -3
- package/{scripts → dist/scripts}/utils.js +33 -27
- package/{scripts → dist/scripts}/work-on.js +55 -41
- package/package.json +52 -50
- 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/bin/cli.ts +0 -125
- package/scripts/done-job.ts +0 -263
- package/scripts/init.js +0 -331
- package/scripts/init.ts +0 -375
- package/scripts/sync.js +0 -178
- package/scripts/sync.ts +0 -211
- package/scripts/utils.ts +0 -236
- package/scripts/work-on.ts +0 -161
- /package/{bin → dist/bin}/cli.d.ts +0 -0
- /package/{scripts/init.d.ts → dist/scripts/config.d.ts} +0 -0
- /package/{scripts → dist/scripts}/done-job.d.ts +0 -0
- /package/{scripts → dist/scripts}/sync.d.ts +0 -0
- /package/{scripts → dist/scripts}/work-on.d.ts +0 -0
|
@@ -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,247 @@
|
|
|
1
|
+
import { getLinearClient, getPrioritySortIndex, getTeamId, loadConfig, loadCycleData, loadLocalConfig, saveConfig, saveCycleData, } from "./utils.js";
|
|
2
|
+
async function 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]
|
|
7
|
+
|
|
8
|
+
Sync issues from Linear to local cycle.ttt file.
|
|
9
|
+
|
|
10
|
+
Arguments:
|
|
11
|
+
issue-id Optional. Sync only this specific issue (e.g., MP-624)
|
|
12
|
+
|
|
13
|
+
What it does:
|
|
14
|
+
- Fetches active cycle from Linear
|
|
15
|
+
- Downloads all issues matching configured filters
|
|
16
|
+
- Preserves local status for existing tasks
|
|
17
|
+
- Updates config with new cycle info
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
ttt sync # Sync all matching issues
|
|
21
|
+
ttt sync MP-624 # Sync only this specific issue
|
|
22
|
+
ttt sync -d .ttt # Sync using .ttt directory`);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
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);
|
|
44
|
+
}
|
|
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).");
|
|
74
|
+
}
|
|
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);
|
|
80
|
+
}
|
|
81
|
+
console.log(`Current cycle: ${cycleName}`);
|
|
82
|
+
}
|
|
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",
|
|
94
|
+
};
|
|
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
|
+
}
|
|
246
|
+
}
|
|
247
|
+
sync().catch(console.error);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LinearClient } from
|
|
1
|
+
import { LinearClient } from "@linear/sdk";
|
|
2
2
|
export declare function getPaths(): {
|
|
3
3
|
baseDir: string;
|
|
4
4
|
configPath: string;
|
|
@@ -27,6 +27,12 @@ export interface CycleInfo {
|
|
|
27
27
|
start_date: string;
|
|
28
28
|
end_date: string;
|
|
29
29
|
}
|
|
30
|
+
export interface StatusTransitions {
|
|
31
|
+
todo: string;
|
|
32
|
+
in_progress: string;
|
|
33
|
+
done: string;
|
|
34
|
+
testing?: string;
|
|
35
|
+
}
|
|
30
36
|
export interface Config {
|
|
31
37
|
teams: Record<string, TeamConfig>;
|
|
32
38
|
users: Record<string, UserConfig>;
|
|
@@ -39,7 +45,7 @@ export interface Config {
|
|
|
39
45
|
name: string;
|
|
40
46
|
type: string;
|
|
41
47
|
}>;
|
|
42
|
-
status_transitions?:
|
|
48
|
+
status_transitions?: StatusTransitions;
|
|
43
49
|
priority_order?: string[];
|
|
44
50
|
current_cycle?: CycleInfo;
|
|
45
51
|
cycle_history?: CycleInfo[];
|
|
@@ -64,7 +70,7 @@ export interface Task {
|
|
|
64
70
|
linearId: string;
|
|
65
71
|
title: string;
|
|
66
72
|
status: string;
|
|
67
|
-
localStatus:
|
|
73
|
+
localStatus: "pending" | "in-progress" | "completed" | "blocked-backend";
|
|
68
74
|
assignee?: string;
|
|
69
75
|
priority: number;
|
|
70
76
|
labels: string[];
|
|
@@ -85,7 +91,9 @@ export interface CycleData {
|
|
|
85
91
|
export interface LocalConfig {
|
|
86
92
|
current_user: string;
|
|
87
93
|
team: string;
|
|
94
|
+
teams?: string[];
|
|
88
95
|
exclude_assignees?: string[];
|
|
96
|
+
exclude_labels?: string[];
|
|
89
97
|
label?: string;
|
|
90
98
|
}
|
|
91
99
|
export declare function fileExists(filePath: string): Promise<boolean>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
3
|
-
import { LinearClient } from
|
|
4
|
-
import { decode, encode } from
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { LinearClient } from "@linear/sdk";
|
|
4
|
+
import { decode, encode } from "@toon-format/toon";
|
|
5
5
|
// Resolve base directory - supports multiple configuration methods
|
|
6
6
|
function getBaseDir() {
|
|
7
7
|
// 1. Check for TOON_DIR environment variable (set by CLI or user)
|
|
@@ -13,12 +13,12 @@ function getBaseDir() {
|
|
|
13
13
|
return path.resolve(process.env.LINEAR_TOON_DIR);
|
|
14
14
|
}
|
|
15
15
|
// 3. Default: .ttt directory in current working directory
|
|
16
|
-
return path.join(process.cwd(),
|
|
16
|
+
return path.join(process.cwd(), ".ttt");
|
|
17
17
|
}
|
|
18
18
|
const BASE_DIR = getBaseDir();
|
|
19
|
-
const CONFIG_PATH = path.join(BASE_DIR,
|
|
20
|
-
const CYCLE_PATH = path.join(BASE_DIR,
|
|
21
|
-
const LOCAL_PATH = path.join(BASE_DIR,
|
|
19
|
+
const CONFIG_PATH = path.join(BASE_DIR, "config.toon");
|
|
20
|
+
const CYCLE_PATH = path.join(BASE_DIR, "cycle.toon");
|
|
21
|
+
const LOCAL_PATH = path.join(BASE_DIR, "local.toon");
|
|
22
22
|
export function getPaths() {
|
|
23
23
|
return {
|
|
24
24
|
baseDir: BASE_DIR,
|
|
@@ -29,16 +29,22 @@ export function getPaths() {
|
|
|
29
29
|
}
|
|
30
30
|
// Linear priority value to name mapping (fixed by Linear API)
|
|
31
31
|
export const PRIORITY_NAMES = {
|
|
32
|
-
0:
|
|
33
|
-
1:
|
|
34
|
-
2:
|
|
35
|
-
3:
|
|
36
|
-
4:
|
|
32
|
+
0: "none",
|
|
33
|
+
1: "urgent",
|
|
34
|
+
2: "high",
|
|
35
|
+
3: "medium",
|
|
36
|
+
4: "low",
|
|
37
37
|
};
|
|
38
|
-
export const DEFAULT_PRIORITY_ORDER = [
|
|
38
|
+
export const DEFAULT_PRIORITY_ORDER = [
|
|
39
|
+
"urgent",
|
|
40
|
+
"high",
|
|
41
|
+
"medium",
|
|
42
|
+
"low",
|
|
43
|
+
"none",
|
|
44
|
+
];
|
|
39
45
|
export function getPrioritySortIndex(priority, priorityOrder) {
|
|
40
46
|
const order = priorityOrder ?? DEFAULT_PRIORITY_ORDER;
|
|
41
|
-
const name = PRIORITY_NAMES[priority] ??
|
|
47
|
+
const name = PRIORITY_NAMES[priority] ?? "none";
|
|
42
48
|
const index = order.indexOf(name);
|
|
43
49
|
return index === -1 ? order.length : index;
|
|
44
50
|
}
|
|
@@ -53,23 +59,23 @@ export async function fileExists(filePath) {
|
|
|
53
59
|
}
|
|
54
60
|
export async function loadConfig() {
|
|
55
61
|
try {
|
|
56
|
-
const fileContent = await fs.readFile(CONFIG_PATH,
|
|
62
|
+
const fileContent = await fs.readFile(CONFIG_PATH, "utf-8");
|
|
57
63
|
return decode(fileContent);
|
|
58
64
|
}
|
|
59
65
|
catch (error) {
|
|
60
66
|
console.error(`Error loading config from ${CONFIG_PATH}:`, error);
|
|
61
|
-
console.error(
|
|
67
|
+
console.error("Run `bun run init` to create configuration files.");
|
|
62
68
|
process.exit(1);
|
|
63
69
|
}
|
|
64
70
|
}
|
|
65
71
|
export async function loadLocalConfig() {
|
|
66
72
|
try {
|
|
67
|
-
const fileContent = await fs.readFile(LOCAL_PATH,
|
|
73
|
+
const fileContent = await fs.readFile(LOCAL_PATH, "utf-8");
|
|
68
74
|
return decode(fileContent);
|
|
69
75
|
}
|
|
70
76
|
catch {
|
|
71
77
|
console.error(`Error: ${LOCAL_PATH} not found.`);
|
|
72
|
-
console.error(
|
|
78
|
+
console.error("Run `bun run init` to create local configuration.");
|
|
73
79
|
process.exit(1);
|
|
74
80
|
}
|
|
75
81
|
}
|
|
@@ -79,7 +85,7 @@ export async function getUserEmail() {
|
|
|
79
85
|
const user = config.users[localConfig.current_user];
|
|
80
86
|
if (!user) {
|
|
81
87
|
console.error(`Error: User "${localConfig.current_user}" not found in config.toon`);
|
|
82
|
-
console.error(`Available users: ${Object.keys(config.users).join(
|
|
88
|
+
console.error(`Available users: ${Object.keys(config.users).join(", ")}`);
|
|
83
89
|
process.exit(1);
|
|
84
90
|
}
|
|
85
91
|
return user.email;
|
|
@@ -87,7 +93,7 @@ export async function getUserEmail() {
|
|
|
87
93
|
export function getLinearClient() {
|
|
88
94
|
const apiKey = process.env.LINEAR_API_KEY;
|
|
89
95
|
if (!apiKey) {
|
|
90
|
-
console.error(
|
|
96
|
+
console.error("Error: LINEAR_API_KEY environment variable is not set.");
|
|
91
97
|
console.error('Set it in your shell: export LINEAR_API_KEY="lin_api_xxxxx"');
|
|
92
98
|
process.exit(1);
|
|
93
99
|
}
|
|
@@ -96,7 +102,7 @@ export function getLinearClient() {
|
|
|
96
102
|
export async function loadCycleData() {
|
|
97
103
|
try {
|
|
98
104
|
await fs.access(CYCLE_PATH);
|
|
99
|
-
const fileContent = await fs.readFile(CYCLE_PATH,
|
|
105
|
+
const fileContent = await fs.readFile(CYCLE_PATH, "utf-8");
|
|
100
106
|
return decode(fileContent);
|
|
101
107
|
}
|
|
102
108
|
catch {
|
|
@@ -105,21 +111,21 @@ export async function loadCycleData() {
|
|
|
105
111
|
}
|
|
106
112
|
export async function saveCycleData(data) {
|
|
107
113
|
const toonString = encode(data);
|
|
108
|
-
await fs.writeFile(CYCLE_PATH, toonString,
|
|
114
|
+
await fs.writeFile(CYCLE_PATH, toonString, "utf-8");
|
|
109
115
|
}
|
|
110
116
|
export async function saveConfig(config) {
|
|
111
117
|
const toonString = encode(config);
|
|
112
|
-
await fs.writeFile(CONFIG_PATH, toonString,
|
|
118
|
+
await fs.writeFile(CONFIG_PATH, toonString, "utf-8");
|
|
113
119
|
}
|
|
114
120
|
export async function saveLocalConfig(config) {
|
|
115
121
|
const toonString = encode(config);
|
|
116
|
-
await fs.writeFile(LOCAL_PATH, toonString,
|
|
122
|
+
await fs.writeFile(LOCAL_PATH, toonString, "utf-8");
|
|
117
123
|
}
|
|
118
124
|
// Get first team key from config
|
|
119
125
|
export function getDefaultTeamKey(config) {
|
|
120
126
|
const keys = Object.keys(config.teams);
|
|
121
127
|
if (keys.length === 0) {
|
|
122
|
-
console.error(
|
|
128
|
+
console.error("Error: No teams defined in config.toon");
|
|
123
129
|
process.exit(1);
|
|
124
130
|
}
|
|
125
131
|
return keys[0];
|
|
@@ -130,7 +136,7 @@ export function getTeamId(config, teamKey) {
|
|
|
130
136
|
const team = config.teams[key];
|
|
131
137
|
if (!team) {
|
|
132
138
|
console.error(`Error: Team "${key}" not found in config.toon`);
|
|
133
|
-
console.error(`Available teams: ${Object.keys(config.teams).join(
|
|
139
|
+
console.error(`Available teams: ${Object.keys(config.teams).join(", ")}`);
|
|
134
140
|
process.exit(1);
|
|
135
141
|
}
|
|
136
142
|
return team.id;
|