team-toon-tack 1.7.1 → 2.0.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/LICENSE +21 -0
- package/README.md +16 -18
- package/README.zh-TW.md +16 -18
- package/dist/scripts/done-job.js +23 -3
- package/dist/scripts/init.js +297 -15
- package/dist/scripts/lib/config-builder.d.ts +1 -1
- package/dist/scripts/lib/config-builder.js +5 -1
- package/dist/scripts/lib/display.js +14 -3
- package/dist/scripts/lib/images.d.ts +9 -0
- package/dist/scripts/lib/images.js +107 -0
- package/dist/scripts/lib/linear.d.ts +1 -1
- package/dist/scripts/lib/linear.js +2 -0
- package/dist/scripts/status.js +15 -15
- package/dist/scripts/sync.js +100 -9
- package/dist/scripts/utils.d.ts +6 -1
- package/dist/scripts/utils.js +2 -0
- package/dist/scripts/work-on.js +8 -2
- package/package.json +1 -1
- package/templates/claude-code-commands/work-on.md +12 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Wei Hung
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
[繁體中文](./README.zh-TW.md) | English
|
|
4
4
|
|
|
5
|
-
Optimized Linear workflow for Claude Code —
|
|
5
|
+
Optimized Linear workflow for Claude Code — saves significant tokens compared to MCP.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **Token Efficient** — Local cycle cache eliminates repeated API calls, saving
|
|
9
|
+
- **Token Efficient** — Local cycle cache eliminates repeated API calls, saving significant tokens vs Linear MCP
|
|
10
10
|
- **Smart Task Selection** — Auto-pick highest priority unassigned work with `/work-on next`
|
|
11
11
|
- **Multi-team Support** — Sync and filter issues across multiple teams
|
|
12
|
+
- **Flexible Sync Modes** — Choose between remote (immediate Linear sync) or local (offline-first, sync later with `--update`)
|
|
13
|
+
- **QA/PM Team Support** — Auto-update parent issues in QA/PM team to "Testing" when completing dev tasks
|
|
14
|
+
- **Attachment Download** — Auto-download Linear images and files to local `.ttt/output/` for AI vision analysis
|
|
15
|
+
- **Blocked Status** — Set tasks as blocked when waiting on external dependencies
|
|
16
|
+
- **Auto Command Setup** — `ttt init` can install Claude Code commands with custom prefix
|
|
12
17
|
- **Cycle History** — Local `.toon` files preserve cycle data for AI context
|
|
13
18
|
- **User Filtering** — Only see issues assigned to you or unassigned
|
|
14
19
|
|
|
@@ -24,22 +29,12 @@ cd your-project
|
|
|
24
29
|
ttt init
|
|
25
30
|
```
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
During init, you'll configure:
|
|
33
|
+
- **Status source**: `remote` (update Linear immediately) or `local` (work offline, sync with `ttt sync --update`)
|
|
34
|
+
- **QA/PM team**: For cross-team parent issue updates (parent must be set in Linear)
|
|
35
|
+
- **Claude Code commands**: Auto-install with optional prefix (e.g., `/ttt:work-on`)
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
cp -r /path/to/team-toon-tack/templates/claude-code-commands/* .claude/commands/
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Edit `.claude/commands/work-on.md` lines 37-40 to add your project's verification steps:
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
# Example: Add your checks here
|
|
37
|
-
bun run typecheck
|
|
38
|
-
bun run lint
|
|
39
|
-
bun run test
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### 3. Daily Workflow
|
|
37
|
+
### 2. Daily Workflow
|
|
43
38
|
|
|
44
39
|
In Claude Code:
|
|
45
40
|
|
|
@@ -73,6 +68,7 @@ Sync current cycle issues from Linear.
|
|
|
73
68
|
```bash
|
|
74
69
|
ttt sync # Sync all matching issues
|
|
75
70
|
ttt sync MP-123 # Sync specific issue only
|
|
71
|
+
ttt sync --update # Push local status changes to Linear (for local mode)
|
|
76
72
|
```
|
|
77
73
|
|
|
78
74
|
### `ttt work-on`
|
|
@@ -104,6 +100,7 @@ ttt status # Show current in-progress task
|
|
|
104
100
|
ttt status MP-123 # Show specific issue status
|
|
105
101
|
ttt status MP-123 --set +1 # Move to next status
|
|
106
102
|
ttt status MP-123 --set done # Mark as done
|
|
103
|
+
ttt status MP-123 --set blocked # Set as blocked (waiting on dependency)
|
|
107
104
|
```
|
|
108
105
|
|
|
109
106
|
### `ttt config`
|
|
@@ -126,7 +123,8 @@ your-project/
|
|
|
126
123
|
└── .ttt/
|
|
127
124
|
├── config.toon # Team config (gitignore recommended)
|
|
128
125
|
├── local.toon # Personal settings (gitignore)
|
|
129
|
-
|
|
126
|
+
├── cycle.toon # Current cycle data (auto-generated)
|
|
127
|
+
└── output/ # Downloaded attachments (images, files)
|
|
130
128
|
```
|
|
131
129
|
|
|
132
130
|
### Environment Variables
|
package/README.zh-TW.md
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
繁體中文 | [English](./README.md)
|
|
4
4
|
|
|
5
|
-
為 Claude Code 最佳化的 Linear 工作流 — 比 MCP
|
|
5
|
+
為 Claude Code 最佳化的 Linear 工作流 — 比 MCP 節省大量 token。
|
|
6
6
|
|
|
7
7
|
## 特色功能
|
|
8
8
|
|
|
9
|
-
- **節省 Token** — 本地 cycle 快取避免重複 API 呼叫,比 Linear MCP
|
|
9
|
+
- **節省 Token** — 本地 cycle 快取避免重複 API 呼叫,比 Linear MCP 省下大量 token
|
|
10
10
|
- **智慧任務挑選** — `/work-on next` 自動選擇最高優先級的未指派工作
|
|
11
11
|
- **多團隊支援** — 跨多個團隊同步與過濾 issue
|
|
12
|
+
- **彈性同步模式** — 選擇 remote(即時同步 Linear)或 local(離線優先,稍後用 `--update` 同步)
|
|
13
|
+
- **QA/PM 團隊支援** — 完成開發任務時自動將 QA/PM 團隊的 parent issue 更新為「Testing」
|
|
14
|
+
- **附件下載** — 自動下載 Linear 圖片和檔案到本地 `.ttt/output/`,供 AI 視覺分析
|
|
15
|
+
- **阻塞狀態** — 等待外部依賴時可設定任務為 blocked
|
|
16
|
+
- **自動安裝指令** — `ttt init` 可自動安裝 Claude Code commands,支援自訂前綴
|
|
12
17
|
- **Cycle 歷史保存** — 本地 `.toon` 檔案保留 cycle 資料,方便 AI 檢閱
|
|
13
18
|
- **使用者過濾** — 只顯示指派給你或未指派的工作
|
|
14
19
|
|
|
@@ -24,22 +29,12 @@ cd your-project
|
|
|
24
29
|
ttt init
|
|
25
30
|
```
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
初始化時會設定:
|
|
33
|
+
- **狀態來源**:`remote`(即時更新 Linear)或 `local`(離線工作,用 `ttt sync --update` 同步)
|
|
34
|
+
- **QA/PM 團隊**:跨團隊 parent issue 更新(需在 Linear 設定 parent)
|
|
35
|
+
- **Claude Code commands**:自動安裝,可選前綴(如 `/ttt:work-on`)
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
cp -r /path/to/team-toon-tack/templates/claude-code-commands/* .claude/commands/
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
編輯 `.claude/commands/work-on.md` 第 37-40 行,加入你專案的驗證步驟:
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
# 範例:加入你的檢查
|
|
37
|
-
bun run typecheck
|
|
38
|
-
bun run lint
|
|
39
|
-
bun run test
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### 3. 每日工作流
|
|
37
|
+
### 2. 每日工作流
|
|
43
38
|
|
|
44
39
|
在 Claude Code 中:
|
|
45
40
|
|
|
@@ -73,6 +68,7 @@ ttt init --force # 覆蓋現有配置
|
|
|
73
68
|
```bash
|
|
74
69
|
ttt sync # 同步所有符合條件的 issue
|
|
75
70
|
ttt sync MP-123 # 只同步特定 issue
|
|
71
|
+
ttt sync --update # 將本地狀態推送到 Linear(local 模式用)
|
|
76
72
|
```
|
|
77
73
|
|
|
78
74
|
### `ttt work-on`
|
|
@@ -104,6 +100,7 @@ ttt status # 顯示當前進行中的任務
|
|
|
104
100
|
ttt status MP-123 # 顯示特定 issue 狀態
|
|
105
101
|
ttt status MP-123 --set +1 # 移動到下一狀態
|
|
106
102
|
ttt status MP-123 --set done # 標記為完成
|
|
103
|
+
ttt status MP-123 --set blocked # 設為阻塞(等待外部依賴)
|
|
107
104
|
```
|
|
108
105
|
|
|
109
106
|
### `ttt config`
|
|
@@ -126,7 +123,8 @@ your-project/
|
|
|
126
123
|
└── .ttt/
|
|
127
124
|
├── config.toon # 團隊配置(建議 gitignore)
|
|
128
125
|
├── local.toon # 個人設定(gitignore)
|
|
129
|
-
|
|
126
|
+
├── cycle.toon # 當前 cycle 資料(自動產生)
|
|
127
|
+
└── output/ # 下載的附件(圖片、檔案)
|
|
130
128
|
```
|
|
131
129
|
|
|
132
130
|
### 環境變數
|
package/dist/scripts/done-job.js
CHANGED
|
@@ -104,8 +104,11 @@ Examples:
|
|
|
104
104
|
});
|
|
105
105
|
aiMessage = aiMsgResponse.aiMessage || "";
|
|
106
106
|
}
|
|
107
|
-
// Update Linear
|
|
108
|
-
|
|
107
|
+
// Update Linear (only if status_source is 'remote' or not set)
|
|
108
|
+
const statusSource = localConfig.status_source || "remote";
|
|
109
|
+
if (task.linearId &&
|
|
110
|
+
process.env.LINEAR_API_KEY &&
|
|
111
|
+
statusSource === "remote") {
|
|
109
112
|
const transitions = getStatusTransitions(config);
|
|
110
113
|
// Update issue to Done
|
|
111
114
|
const success = await updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
|
|
@@ -129,7 +132,20 @@ Examples:
|
|
|
129
132
|
if (parentIssue) {
|
|
130
133
|
const parentTeam = await parentIssue.team;
|
|
131
134
|
if (parentTeam) {
|
|
132
|
-
|
|
135
|
+
// Determine which team key to use for parent's workflow states
|
|
136
|
+
// If qa_pm_team is configured and matches parent's team, use it
|
|
137
|
+
// Otherwise, try to find the team key from config
|
|
138
|
+
let parentTeamKey = localConfig.team;
|
|
139
|
+
const teamEntries = Object.entries(config.teams);
|
|
140
|
+
const matchingTeam = teamEntries.find(([_, t]) => t.id === parentTeam.id);
|
|
141
|
+
if (matchingTeam) {
|
|
142
|
+
parentTeamKey = matchingTeam[0];
|
|
143
|
+
}
|
|
144
|
+
else if (localConfig.qa_pm_team) {
|
|
145
|
+
// Fallback to qa_pm_team if configured
|
|
146
|
+
parentTeamKey = localConfig.qa_pm_team;
|
|
147
|
+
}
|
|
148
|
+
const parentStates = await getWorkflowStates(config, parentTeamKey);
|
|
133
149
|
const testingState = parentStates.find((s) => s.name === transitions.testing);
|
|
134
150
|
if (testingState) {
|
|
135
151
|
await client.updateIssue(parentIssue.id, {
|
|
@@ -145,6 +161,10 @@ Examples:
|
|
|
145
161
|
}
|
|
146
162
|
}
|
|
147
163
|
}
|
|
164
|
+
else if (statusSource === "local") {
|
|
165
|
+
console.log(`Local: ${task.id} marked as completed`);
|
|
166
|
+
console.log(`(Linear status not updated - use 'sync --update' to push)`);
|
|
167
|
+
}
|
|
148
168
|
// Sync full issue data from Linear (including new comment)
|
|
149
169
|
const syncedTask = await syncSingleIssue(task.id, {
|
|
150
170
|
config,
|
package/dist/scripts/init.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { decode, encode } from "@toon-format/toon";
|
|
4
5
|
import prompts from "prompts";
|
|
5
6
|
import { buildConfig, buildLocalConfig, findTeamKey, findUserKey, getDefaultStatusTransitions, } from "./lib/config-builder.js";
|
|
@@ -116,6 +117,40 @@ async function selectTeams(teams, options) {
|
|
|
116
117
|
}
|
|
117
118
|
return { selected: selectedTeams, primary: primaryTeam };
|
|
118
119
|
}
|
|
120
|
+
async function selectQaPmTeam(teams, primaryTeam, options) {
|
|
121
|
+
// Only ask if there are multiple teams and interactive mode
|
|
122
|
+
if (!options.interactive || teams.length <= 1) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
// Filter out primary team from choices
|
|
126
|
+
const otherTeams = teams.filter((t) => t.id !== primaryTeam.id);
|
|
127
|
+
if (otherTeams.length === 0) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
console.log("\n🔗 QA/PM Team Configuration:");
|
|
131
|
+
const response = await prompts({
|
|
132
|
+
type: "select",
|
|
133
|
+
name: "qaPmTeamId",
|
|
134
|
+
message: "Select QA/PM team (for cross-team parent issue updates):",
|
|
135
|
+
choices: [
|
|
136
|
+
{
|
|
137
|
+
title: "(None - skip)",
|
|
138
|
+
value: undefined,
|
|
139
|
+
description: "No cross-team parent updates",
|
|
140
|
+
},
|
|
141
|
+
...otherTeams.map((t) => ({
|
|
142
|
+
title: t.name,
|
|
143
|
+
value: t.id,
|
|
144
|
+
description: "Parent issues in this team will be updated to Testing",
|
|
145
|
+
})),
|
|
146
|
+
],
|
|
147
|
+
initial: 0,
|
|
148
|
+
});
|
|
149
|
+
if (!response.qaPmTeamId) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
return teams.find((t) => t.id === response.qaPmTeamId);
|
|
153
|
+
}
|
|
119
154
|
async function selectUser(users, options) {
|
|
120
155
|
let currentUser = users[0];
|
|
121
156
|
if (options.user) {
|
|
@@ -157,6 +192,31 @@ async function selectLabelFilter(labels, options) {
|
|
|
157
192
|
}
|
|
158
193
|
return undefined;
|
|
159
194
|
}
|
|
195
|
+
async function selectStatusSource(options) {
|
|
196
|
+
if (!options.interactive) {
|
|
197
|
+
return "remote"; // default
|
|
198
|
+
}
|
|
199
|
+
console.log("\n🔄 Configure status sync mode:");
|
|
200
|
+
const response = await prompts({
|
|
201
|
+
type: "select",
|
|
202
|
+
name: "statusSource",
|
|
203
|
+
message: "Where should status updates be stored?",
|
|
204
|
+
choices: [
|
|
205
|
+
{
|
|
206
|
+
title: "Remote (recommended)",
|
|
207
|
+
value: "remote",
|
|
208
|
+
description: "Update Linear immediately when you work-on or complete tasks",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
title: "Local",
|
|
212
|
+
value: "local",
|
|
213
|
+
description: "Work offline, then sync to Linear with 'sync --update'",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
initial: 0,
|
|
217
|
+
});
|
|
218
|
+
return response.statusSource || "remote";
|
|
219
|
+
}
|
|
160
220
|
async function selectStatusMappings(states, options) {
|
|
161
221
|
const defaults = getDefaultStatusTransitions(states);
|
|
162
222
|
if (!options.interactive || states.length === 0) {
|
|
@@ -201,11 +261,25 @@ async function selectStatusMappings(states, options) {
|
|
|
201
261
|
? testingChoices.findIndex((c) => c.value === defaults.testing)
|
|
202
262
|
: 0,
|
|
203
263
|
});
|
|
264
|
+
const blockedChoices = [
|
|
265
|
+
{ title: "(None)", value: undefined },
|
|
266
|
+
...stateChoices,
|
|
267
|
+
];
|
|
268
|
+
const blockedResponse = await prompts({
|
|
269
|
+
type: "select",
|
|
270
|
+
name: "blocked",
|
|
271
|
+
message: 'Select status for "Blocked" (optional, for blocked tasks):',
|
|
272
|
+
choices: blockedChoices,
|
|
273
|
+
initial: defaults.blocked
|
|
274
|
+
? blockedChoices.findIndex((c) => c.value === defaults.blocked)
|
|
275
|
+
: 0,
|
|
276
|
+
});
|
|
204
277
|
return {
|
|
205
278
|
todo: todoResponse.todo || defaults.todo,
|
|
206
279
|
in_progress: inProgressResponse.in_progress || defaults.in_progress,
|
|
207
280
|
done: doneResponse.done || defaults.done,
|
|
208
281
|
testing: testingResponse.testing,
|
|
282
|
+
blocked: blockedResponse.blocked,
|
|
209
283
|
};
|
|
210
284
|
}
|
|
211
285
|
async function updateGitignore(tttDir, interactive) {
|
|
@@ -254,6 +328,148 @@ async function updateGitignore(tttDir, interactive) {
|
|
|
254
328
|
// Silently ignore gitignore errors
|
|
255
329
|
}
|
|
256
330
|
}
|
|
331
|
+
async function installClaudeCommands(interactive, statusSource) {
|
|
332
|
+
if (!interactive) {
|
|
333
|
+
return { installed: false, prefix: "" };
|
|
334
|
+
}
|
|
335
|
+
console.log("\n🤖 Claude Code Commands:");
|
|
336
|
+
// Ask if user wants to install commands
|
|
337
|
+
const { install } = await prompts({
|
|
338
|
+
type: "confirm",
|
|
339
|
+
name: "install",
|
|
340
|
+
message: "Install Claude Code commands? (work-on, done-job, sync-linear)",
|
|
341
|
+
initial: true,
|
|
342
|
+
});
|
|
343
|
+
if (!install) {
|
|
344
|
+
return { installed: false, prefix: "" };
|
|
345
|
+
}
|
|
346
|
+
// Ask for prefix
|
|
347
|
+
const { prefixChoice } = await prompts({
|
|
348
|
+
type: "select",
|
|
349
|
+
name: "prefixChoice",
|
|
350
|
+
message: "Command prefix style:",
|
|
351
|
+
choices: [
|
|
352
|
+
{
|
|
353
|
+
title: "No prefix (recommended)",
|
|
354
|
+
value: "",
|
|
355
|
+
description: "/work-on, /done-job, /sync-linear",
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
title: "ttt:",
|
|
359
|
+
value: "ttt:",
|
|
360
|
+
description: "/ttt:work-on, /ttt:done-job, /ttt:sync-linear",
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
title: "linear:",
|
|
364
|
+
value: "linear:",
|
|
365
|
+
description: "/linear:work-on, /linear:done-job, /linear:sync-linear",
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
title: "Custom...",
|
|
369
|
+
value: "custom",
|
|
370
|
+
description: "Enter your own prefix",
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
initial: 0,
|
|
374
|
+
});
|
|
375
|
+
let prefix = prefixChoice || "";
|
|
376
|
+
if (prefixChoice === "custom") {
|
|
377
|
+
const { customPrefix } = await prompts({
|
|
378
|
+
type: "text",
|
|
379
|
+
name: "customPrefix",
|
|
380
|
+
message: "Enter custom prefix (e.g., 'my:'):",
|
|
381
|
+
initial: "",
|
|
382
|
+
});
|
|
383
|
+
prefix = customPrefix || "";
|
|
384
|
+
}
|
|
385
|
+
// Find templates directory
|
|
386
|
+
// Try multiple locations: installed package, local dev
|
|
387
|
+
const possibleTemplatePaths = [
|
|
388
|
+
path.join(__dirname, "..", "templates", "claude-code-commands"),
|
|
389
|
+
path.join(__dirname, "..", "..", "templates", "claude-code-commands"),
|
|
390
|
+
path.join(process.cwd(), "templates", "claude-code-commands"),
|
|
391
|
+
];
|
|
392
|
+
let templateDir = null;
|
|
393
|
+
for (const p of possibleTemplatePaths) {
|
|
394
|
+
try {
|
|
395
|
+
await fs.access(p);
|
|
396
|
+
templateDir = p;
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
// Try next path
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (!templateDir) {
|
|
404
|
+
// Try to get repo URL from package.json
|
|
405
|
+
let repoUrl = "https://github.com/wayne930242/team-toon-tack";
|
|
406
|
+
try {
|
|
407
|
+
const pkgPaths = [
|
|
408
|
+
path.join(__dirname, "..", "package.json"),
|
|
409
|
+
path.join(__dirname, "..", "..", "package.json"),
|
|
410
|
+
];
|
|
411
|
+
for (const pkgPath of pkgPaths) {
|
|
412
|
+
try {
|
|
413
|
+
const pkgContent = await fs.readFile(pkgPath, "utf-8");
|
|
414
|
+
const pkg = JSON.parse(pkgContent);
|
|
415
|
+
if (pkg.repository?.url) {
|
|
416
|
+
// Parse git+https://github.com/user/repo.git format
|
|
417
|
+
repoUrl = pkg.repository.url
|
|
418
|
+
.replace(/^git\+/, "")
|
|
419
|
+
.replace(/\.git$/, "");
|
|
420
|
+
}
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// Try next path
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// Use default URL
|
|
430
|
+
}
|
|
431
|
+
console.log(" ⚠ Could not find command templates. Please copy manually from:");
|
|
432
|
+
console.log(` ${repoUrl}/tree/main/templates/claude-code-commands`);
|
|
433
|
+
return { installed: false, prefix };
|
|
434
|
+
}
|
|
435
|
+
// Create .claude/commands directory
|
|
436
|
+
const commandsDir = path.join(process.cwd(), ".claude", "commands");
|
|
437
|
+
await fs.mkdir(commandsDir, { recursive: true });
|
|
438
|
+
// Copy and rename template files
|
|
439
|
+
const templateFiles = await fs.readdir(templateDir);
|
|
440
|
+
const commandFiles = templateFiles.filter((f) => f.endsWith(".md"));
|
|
441
|
+
for (const file of commandFiles) {
|
|
442
|
+
const baseName = file.replace(".md", "");
|
|
443
|
+
const newFileName = prefix ? `${prefix}${baseName}.md` : file;
|
|
444
|
+
const srcPath = path.join(templateDir, file);
|
|
445
|
+
const destPath = path.join(commandsDir, newFileName);
|
|
446
|
+
// Read template content
|
|
447
|
+
let content = await fs.readFile(srcPath, "utf-8");
|
|
448
|
+
// Update the name in frontmatter if prefix is used
|
|
449
|
+
if (prefix) {
|
|
450
|
+
content = content.replace(/^(---\s*\n[\s\S]*?name:\s*)(\S+)/m, `$1${prefix}${baseName}`);
|
|
451
|
+
}
|
|
452
|
+
// Modify content based on statusSource for work-on and done-job
|
|
453
|
+
if (statusSource === "local") {
|
|
454
|
+
if (baseName === "work-on" || baseName.endsWith("work-on")) {
|
|
455
|
+
// Update description for local mode
|
|
456
|
+
content = content.replace(/Select a task and update status to "In Progress" on both local and Linear\./, 'Select a task and update local status to "In Progress". (Linear will be updated when you run `sync --update`)');
|
|
457
|
+
// Add reminder after Complete section
|
|
458
|
+
content = content.replace(/Use `?\/done-job`? to mark task as completed/, "Use `/done-job` to mark task as completed\n\n### 7. Sync to Linear\n\nWhen ready to update Linear with all your changes:\n\n```bash\nttt sync --update\n```");
|
|
459
|
+
}
|
|
460
|
+
if (baseName === "done-job" || baseName.endsWith("done-job")) {
|
|
461
|
+
// Update description for local mode
|
|
462
|
+
content = content.replace(/Mark a task as done and update Linear with commit details\./, "Mark a task as done locally. (Run `ttt sync --update` to push changes to Linear)");
|
|
463
|
+
// Add reminder at the end
|
|
464
|
+
content = content.replace(/## What It Does\n\n- Linear issue status → "Done"/, "## What It Does\n\n- Local status → `completed`");
|
|
465
|
+
content += `\n## Sync to Linear\n\nAfter completing tasks, push all changes to Linear:\n\n\`\`\`bash\nttt sync --update\n\`\`\`\n`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
await fs.writeFile(destPath, content, "utf-8");
|
|
469
|
+
console.log(` ✓ .claude/commands/${newFileName}`);
|
|
470
|
+
}
|
|
471
|
+
return { installed: true, prefix };
|
|
472
|
+
}
|
|
257
473
|
async function init() {
|
|
258
474
|
const args = process.argv.slice(2);
|
|
259
475
|
const options = parseArgs(args);
|
|
@@ -308,24 +524,62 @@ async function init() {
|
|
|
308
524
|
if (selectedTeams.length > 1) {
|
|
309
525
|
console.log(` Primary: ${primaryTeam.name}`);
|
|
310
526
|
}
|
|
311
|
-
// Fetch data from primary team
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
527
|
+
// Fetch data from ALL teams (not just primary) to support cross-team operations
|
|
528
|
+
console.log(` Fetching data from ${teams.length} teams...`);
|
|
529
|
+
// Collect users, labels, states from all teams
|
|
530
|
+
const allUsers = [];
|
|
531
|
+
const allLabels = [];
|
|
532
|
+
const allStates = [];
|
|
533
|
+
const seenUserIds = new Set();
|
|
534
|
+
const seenLabelIds = new Set();
|
|
535
|
+
const seenStateIds = new Set();
|
|
536
|
+
for (const team of teams) {
|
|
537
|
+
try {
|
|
538
|
+
const teamData = await client.team(team.id);
|
|
539
|
+
const members = await teamData.members();
|
|
540
|
+
for (const user of members.nodes) {
|
|
541
|
+
if (!seenUserIds.has(user.id)) {
|
|
542
|
+
seenUserIds.add(user.id);
|
|
543
|
+
allUsers.push(user);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const labelsData = await client.issueLabels({
|
|
547
|
+
filter: { team: { id: { eq: team.id } } },
|
|
548
|
+
});
|
|
549
|
+
for (const label of labelsData.nodes) {
|
|
550
|
+
if (!seenLabelIds.has(label.id)) {
|
|
551
|
+
seenLabelIds.add(label.id);
|
|
552
|
+
allLabels.push(label);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const statesData = await client.workflowStates({
|
|
556
|
+
filter: { team: { id: { eq: team.id } } },
|
|
557
|
+
});
|
|
558
|
+
for (const state of statesData.nodes) {
|
|
559
|
+
if (!seenStateIds.has(state.id)) {
|
|
560
|
+
seenStateIds.add(state.id);
|
|
561
|
+
allStates.push(state);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
console.warn(` ⚠ Could not fetch data for team ${team.name}, skipping...`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const users = allUsers;
|
|
570
|
+
const labels = allLabels;
|
|
571
|
+
const states = allStates;
|
|
315
572
|
console.log(` Users: ${users.length}`);
|
|
316
|
-
const labelsData = await client.issueLabels({
|
|
317
|
-
filter: { team: { id: { eq: primaryTeam.id } } },
|
|
318
|
-
});
|
|
319
|
-
const labels = labelsData.nodes;
|
|
320
573
|
console.log(` Labels: ${labels.length}`);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const states = statesData.nodes;
|
|
574
|
+
console.log(` Workflow states: ${states.length}`);
|
|
575
|
+
// Get cycle from primary team (for current work tracking)
|
|
576
|
+
const selectedTeam = await client.team(primaryTeam.id);
|
|
325
577
|
const currentCycle = (await selectedTeam.activeCycle);
|
|
326
578
|
// User selections
|
|
327
579
|
const currentUser = await selectUser(users, options);
|
|
328
580
|
const defaultLabel = await selectLabelFilter(labels, options);
|
|
581
|
+
const statusSource = await selectStatusSource(options);
|
|
582
|
+
const qaPmTeam = await selectQaPmTeam(teams, primaryTeam, options);
|
|
329
583
|
const statusTransitions = await selectStatusMappings(states, options);
|
|
330
584
|
// Build config
|
|
331
585
|
const config = buildConfig(teams, users, labels, states, statusTransitions, currentCycle ?? undefined);
|
|
@@ -335,7 +589,11 @@ async function init() {
|
|
|
335
589
|
const selectedTeamKeys = selectedTeams
|
|
336
590
|
.map((team) => findTeamKey(config.teams, team.id))
|
|
337
591
|
.filter((key) => key !== undefined);
|
|
338
|
-
const
|
|
592
|
+
const qaPmTeamKey = qaPmTeam
|
|
593
|
+
? findTeamKey(config.teams, qaPmTeam.id)
|
|
594
|
+
: undefined;
|
|
595
|
+
const localConfig = buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, undefined, // excludeLabels
|
|
596
|
+
statusSource, qaPmTeamKey);
|
|
339
597
|
// Write config files
|
|
340
598
|
console.log("\n📝 Writing configuration files...");
|
|
341
599
|
await fs.mkdir(paths.baseDir, { recursive: true });
|
|
@@ -372,10 +630,14 @@ async function init() {
|
|
|
372
630
|
localConfig.team = existingLocal.team;
|
|
373
631
|
if (existingLocal.teams)
|
|
374
632
|
localConfig.teams = existingLocal.teams;
|
|
633
|
+
if (existingLocal.qa_pm_team)
|
|
634
|
+
localConfig.qa_pm_team = existingLocal.qa_pm_team;
|
|
375
635
|
if (existingLocal.label)
|
|
376
636
|
localConfig.label = existingLocal.label;
|
|
377
637
|
if (existingLocal.exclude_labels)
|
|
378
638
|
localConfig.exclude_labels = existingLocal.exclude_labels;
|
|
639
|
+
if (existingLocal.status_source)
|
|
640
|
+
localConfig.status_source = existingLocal.status_source;
|
|
379
641
|
}
|
|
380
642
|
}
|
|
381
643
|
catch {
|
|
@@ -387,6 +649,8 @@ async function init() {
|
|
|
387
649
|
// Update .gitignore
|
|
388
650
|
const tttDir = paths.baseDir.replace(/^\.\//, "");
|
|
389
651
|
await updateGitignore(tttDir, options.interactive ?? true);
|
|
652
|
+
// Install Claude Code commands
|
|
653
|
+
const { installed: commandsInstalled, prefix: commandPrefix } = await installClaudeCommands(options.interactive ?? true, statusSource);
|
|
390
654
|
// Summary
|
|
391
655
|
console.log("\n✅ Initialization complete!\n");
|
|
392
656
|
console.log("Configuration summary:");
|
|
@@ -396,6 +660,10 @@ async function init() {
|
|
|
396
660
|
}
|
|
397
661
|
console.log(` User: ${currentUser.displayName || currentUser.name} (${currentUser.email})`);
|
|
398
662
|
console.log(` Label filter: ${defaultLabel || "(none)"}`);
|
|
663
|
+
console.log(` Status source: ${statusSource === "local" ? "local (use 'sync --update' to push)" : "remote (immediate sync)"}`);
|
|
664
|
+
if (qaPmTeam) {
|
|
665
|
+
console.log(` QA/PM team: ${qaPmTeam.name}`);
|
|
666
|
+
}
|
|
399
667
|
console.log(` (Use 'ttt config filters' to set excluded labels/users)`);
|
|
400
668
|
if (currentCycle) {
|
|
401
669
|
console.log(` Cycle: ${currentCycle.name || `Cycle #${currentCycle.number}`}`);
|
|
@@ -407,10 +675,24 @@ async function init() {
|
|
|
407
675
|
if (statusTransitions.testing) {
|
|
408
676
|
console.log(` Testing: ${statusTransitions.testing}`);
|
|
409
677
|
}
|
|
678
|
+
if (statusTransitions.blocked) {
|
|
679
|
+
console.log(` Blocked: ${statusTransitions.blocked}`);
|
|
680
|
+
}
|
|
681
|
+
if (commandsInstalled) {
|
|
682
|
+
const cmdPrefix = commandPrefix ? `${commandPrefix}` : "";
|
|
683
|
+
console.log(` Claude commands: /${cmdPrefix}work-on, /${cmdPrefix}done-job, /${cmdPrefix}sync-linear`);
|
|
684
|
+
}
|
|
410
685
|
console.log("\nNext steps:");
|
|
411
686
|
console.log(" 1. Set LINEAR_API_KEY in your shell profile:");
|
|
412
687
|
console.log(` export LINEAR_API_KEY="${apiKey}"`);
|
|
413
|
-
console.log(" 2. Run sync:
|
|
414
|
-
|
|
688
|
+
console.log(" 2. Run sync: ttt sync");
|
|
689
|
+
if (commandsInstalled) {
|
|
690
|
+
const cmdPrefix = commandPrefix ? `${commandPrefix}` : "";
|
|
691
|
+
console.log(` 3. In Claude Code: /${cmdPrefix}work-on next`);
|
|
692
|
+
console.log(`\n💡 Tip: Edit .claude/commands/${cmdPrefix}work-on.md to customize the "Verify" section for your project.`);
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
console.log(" 3. Start working: ttt work-on");
|
|
696
|
+
}
|
|
415
697
|
}
|
|
416
698
|
init().catch(console.error);
|
|
@@ -38,4 +38,4 @@ export declare function getDefaultStatusTransitions(states: LinearState[]): Stat
|
|
|
38
38
|
export declare function buildConfig(teams: LinearTeam[], users: LinearUser[], labels: LinearLabel[], states: LinearState[], statusTransitions: StatusTransitions, currentCycle?: LinearCycle): Config;
|
|
39
39
|
export declare function findUserKey(usersConfig: Record<string, UserConfig>, userId: string): string;
|
|
40
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;
|
|
41
|
+
export declare function buildLocalConfig(currentUserKey: string, primaryTeamKey: string, selectedTeamKeys: string[], defaultLabel?: string, excludeLabels?: string[], statusSource?: "remote" | "local", qaPmTeam?: string): LocalConfig;
|
|
@@ -67,11 +67,13 @@ export function getDefaultStatusTransitions(states) {
|
|
|
67
67
|
findStatusByKeyword(states, ["done", "complete"]) ||
|
|
68
68
|
"Done";
|
|
69
69
|
const defaultTesting = findStatusByKeyword(states, ["testing", "review"]) || undefined;
|
|
70
|
+
const defaultBlocked = findStatusByKeyword(states, ["blocked", "on hold", "waiting"]) || undefined;
|
|
70
71
|
return {
|
|
71
72
|
todo: defaultTodo,
|
|
72
73
|
in_progress: defaultInProgress,
|
|
73
74
|
done: defaultDone,
|
|
74
75
|
testing: defaultTesting,
|
|
76
|
+
blocked: defaultBlocked,
|
|
75
77
|
};
|
|
76
78
|
}
|
|
77
79
|
export function buildConfig(teams, users, labels, states, statusTransitions, currentCycle) {
|
|
@@ -106,12 +108,14 @@ export function findTeamKey(teamsConfig, teamId) {
|
|
|
106
108
|
return (Object.entries(teamsConfig).find(([_, t]) => t.id === teamId)?.[0] ||
|
|
107
109
|
Object.keys(teamsConfig)[0]);
|
|
108
110
|
}
|
|
109
|
-
export function buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, excludeLabels) {
|
|
111
|
+
export function buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, excludeLabels, statusSource, qaPmTeam) {
|
|
110
112
|
return {
|
|
111
113
|
current_user: currentUserKey,
|
|
112
114
|
team: primaryTeamKey,
|
|
113
115
|
teams: selectedTeamKeys.length > 1 ? selectedTeamKeys : undefined,
|
|
116
|
+
qa_pm_team: qaPmTeam,
|
|
114
117
|
label: defaultLabel,
|
|
115
118
|
exclude_labels: excludeLabels && excludeLabels.length > 0 ? excludeLabels : undefined,
|
|
119
|
+
status_source: statusSource,
|
|
116
120
|
};
|
|
117
121
|
}
|
|
@@ -11,7 +11,7 @@ export function getStatusIcon(localStatus) {
|
|
|
11
11
|
return "✅";
|
|
12
12
|
case "in-progress":
|
|
13
13
|
return "🔄";
|
|
14
|
-
case "blocked
|
|
14
|
+
case "blocked":
|
|
15
15
|
return "🚫";
|
|
16
16
|
default:
|
|
17
17
|
return "📋";
|
|
@@ -39,14 +39,25 @@ export function displayTaskStatus(task) {
|
|
|
39
39
|
}
|
|
40
40
|
export function displayTaskDescription(task) {
|
|
41
41
|
if (task.description) {
|
|
42
|
-
|
|
42
|
+
let description = task.description;
|
|
43
|
+
// Replace Linear URLs with local paths from attachments
|
|
44
|
+
if (task.attachments) {
|
|
45
|
+
for (const att of task.attachments) {
|
|
46
|
+
if (att.localPath && att.url) {
|
|
47
|
+
description = description.split(att.url).join(att.localPath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
console.log(`\n📝 Description:\n${description}`);
|
|
43
52
|
}
|
|
44
53
|
}
|
|
45
54
|
export function displayTaskAttachments(task) {
|
|
46
55
|
if (task.attachments && task.attachments.length > 0) {
|
|
47
56
|
console.log(`\n📎 Attachments:`);
|
|
48
57
|
for (const att of task.attachments) {
|
|
49
|
-
|
|
58
|
+
// Prefer local path for Linear images (Linear URLs are not accessible)
|
|
59
|
+
const displayPath = att.localPath || att.url;
|
|
60
|
+
console.log(` - ${att.title}: ${displayPath}`);
|
|
50
61
|
}
|
|
51
62
|
}
|
|
52
63
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function isLinearImageUrl(url: string): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* Extract Linear image URLs from markdown text (description, comments)
|
|
4
|
+
*/
|
|
5
|
+
export declare function extractLinearImageUrls(text: string): string[];
|
|
6
|
+
export declare function downloadLinearFile(url: string, issueId: string, attachmentId: string, outputDir: string): Promise<string | undefined>;
|
|
7
|
+
export declare const downloadLinearImage: typeof downloadLinearFile;
|
|
8
|
+
export declare function clearIssueImages(outputDir: string, issueId: string): Promise<void>;
|
|
9
|
+
export declare function ensureOutputDir(outputDir: string): Promise<void>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const LINEAR_IMAGE_DOMAINS = [
|
|
4
|
+
"uploads.linear.app",
|
|
5
|
+
"linear-uploads.s3.us-west-2.amazonaws.com",
|
|
6
|
+
];
|
|
7
|
+
export function isLinearImageUrl(url) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = new URL(url);
|
|
10
|
+
return LINEAR_IMAGE_DOMAINS.some((domain) => parsed.host.includes(domain));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Extract Linear image URLs from markdown text (description, comments)
|
|
18
|
+
*/
|
|
19
|
+
export function extractLinearImageUrls(text) {
|
|
20
|
+
const urls = [];
|
|
21
|
+
// Match markdown image syntax  and plain URLs
|
|
22
|
+
const patterns = [
|
|
23
|
+
/!\[[^\]]*\]\((https?:\/\/[^)]+)\)/g, // 
|
|
24
|
+
/(https?:\/\/uploads\.linear\.app\/[^\s)>\]]+)/g, // Plain Linear upload URLs
|
|
25
|
+
];
|
|
26
|
+
for (const pattern of patterns) {
|
|
27
|
+
let match;
|
|
28
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
29
|
+
const url = match[1];
|
|
30
|
+
if (isLinearImageUrl(url) && !urls.includes(url)) {
|
|
31
|
+
urls.push(url);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return urls;
|
|
36
|
+
}
|
|
37
|
+
function getFileExtension(url, contentType) {
|
|
38
|
+
// Try to get extension from content-type header
|
|
39
|
+
if (contentType) {
|
|
40
|
+
// Image types
|
|
41
|
+
const imageMatch = contentType.match(/image\/(\w+)/);
|
|
42
|
+
if (imageMatch) {
|
|
43
|
+
const ext = imageMatch[1] === "jpeg" ? "jpg" : imageMatch[1];
|
|
44
|
+
return { ext, isImage: true };
|
|
45
|
+
}
|
|
46
|
+
// Video types
|
|
47
|
+
const videoMatch = contentType.match(/video\/(\w+)/);
|
|
48
|
+
if (videoMatch) {
|
|
49
|
+
const videoExt = videoMatch[1] === "quicktime" ? "mov" : videoMatch[1];
|
|
50
|
+
return { ext: videoExt, isImage: false };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Fallback: try to get from URL path
|
|
54
|
+
const urlPath = new URL(url).pathname;
|
|
55
|
+
const ext = path.extname(urlPath).slice(1).toLowerCase();
|
|
56
|
+
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
|
|
57
|
+
return { ext: ext === "jpeg" ? "jpg" : ext, isImage: true };
|
|
58
|
+
}
|
|
59
|
+
if (["mov", "mp4", "webm", "avi"].includes(ext)) {
|
|
60
|
+
return { ext, isImage: false };
|
|
61
|
+
}
|
|
62
|
+
return { ext: "png", isImage: true }; // Default assume image
|
|
63
|
+
}
|
|
64
|
+
export async function downloadLinearFile(url, issueId, attachmentId, outputDir) {
|
|
65
|
+
try {
|
|
66
|
+
// Linear files require authentication
|
|
67
|
+
const headers = {};
|
|
68
|
+
if (process.env.LINEAR_API_KEY) {
|
|
69
|
+
headers["Authorization"] = process.env.LINEAR_API_KEY;
|
|
70
|
+
}
|
|
71
|
+
const response = await fetch(url, { headers });
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
console.error(`Failed to download file: ${response.status}`);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const contentType = response.headers.get("content-type") || undefined;
|
|
77
|
+
const { ext } = getFileExtension(url, contentType);
|
|
78
|
+
const filename = `${issueId}_${attachmentId}.${ext}`;
|
|
79
|
+
const filepath = path.join(outputDir, filename);
|
|
80
|
+
const buffer = await response.arrayBuffer();
|
|
81
|
+
await fs.writeFile(filepath, Buffer.from(buffer));
|
|
82
|
+
return filepath;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
console.error(`Error downloading file: ${error}`);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Alias for backwards compatibility
|
|
90
|
+
export const downloadLinearImage = downloadLinearFile;
|
|
91
|
+
export async function clearIssueImages(outputDir, issueId) {
|
|
92
|
+
try {
|
|
93
|
+
const files = await fs.readdir(outputDir);
|
|
94
|
+
const issuePrefix = `${issueId}_`;
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
if (file.startsWith(issuePrefix)) {
|
|
97
|
+
await fs.unlink(path.join(outputDir, file));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Directory doesn't exist or other error, ignore
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function ensureOutputDir(outputDir) {
|
|
106
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
107
|
+
}
|
|
@@ -8,4 +8,4 @@ export declare function getWorkflowStates(config: Config, teamKey: string): Prom
|
|
|
8
8
|
export declare function getStatusTransitions(config: Config): StatusTransitions;
|
|
9
9
|
export declare function updateIssueStatus(linearId: string, targetStatusName: string, config: Config, teamKey: string): Promise<boolean>;
|
|
10
10
|
export declare function addComment(issueId: string, body: string): Promise<boolean>;
|
|
11
|
-
export declare function mapLocalStatusToLinear(localStatus: "pending" | "in-progress" | "completed" | "blocked
|
|
11
|
+
export declare function mapLocalStatusToLinear(localStatus: "pending" | "in-progress" | "completed" | "blocked", config: Config): string | undefined;
|
package/dist/scripts/status.js
CHANGED
|
@@ -6,7 +6,7 @@ const LOCAL_STATUS_ORDER = [
|
|
|
6
6
|
"pending",
|
|
7
7
|
"in-progress",
|
|
8
8
|
"completed",
|
|
9
|
-
"blocked
|
|
9
|
+
"blocked",
|
|
10
10
|
];
|
|
11
11
|
function parseArgs(args) {
|
|
12
12
|
let issueId;
|
|
@@ -41,7 +41,7 @@ Options:
|
|
|
41
41
|
pending Set to pending
|
|
42
42
|
in-progress Set to in-progress
|
|
43
43
|
completed Set to completed
|
|
44
|
-
blocked Set to blocked
|
|
44
|
+
blocked Set to blocked (syncs to Linear if configured)
|
|
45
45
|
todo Set Linear to Todo status
|
|
46
46
|
done Set Linear to Done status
|
|
47
47
|
|
|
@@ -105,17 +105,8 @@ Examples:
|
|
|
105
105
|
const newIndex = Math.max(currentIndex - 2, 0);
|
|
106
106
|
newLocalStatus = LOCAL_STATUS_ORDER[newIndex];
|
|
107
107
|
}
|
|
108
|
-
else if ([
|
|
109
|
-
|
|
110
|
-
"in-progress",
|
|
111
|
-
"completed",
|
|
112
|
-
"blocked-backend",
|
|
113
|
-
"blocked",
|
|
114
|
-
].includes(setStatus)) {
|
|
115
|
-
newLocalStatus =
|
|
116
|
-
setStatus === "blocked"
|
|
117
|
-
? "blocked-backend"
|
|
118
|
-
: setStatus;
|
|
108
|
+
else if (["pending", "in-progress", "completed", "blocked"].includes(setStatus)) {
|
|
109
|
+
newLocalStatus = setStatus;
|
|
119
110
|
}
|
|
120
111
|
else if (["todo", "in_progress", "done", "testing"].includes(setStatus)) {
|
|
121
112
|
const transitions = getStatusTransitions(config);
|
|
@@ -143,8 +134,9 @@ Examples:
|
|
|
143
134
|
task.localStatus = newLocalStatus;
|
|
144
135
|
needsSave = true;
|
|
145
136
|
}
|
|
146
|
-
// Update Linear status
|
|
147
|
-
|
|
137
|
+
// Update Linear status (only if status_source is 'remote' or not set)
|
|
138
|
+
const statusSource = localConfig.status_source || "remote";
|
|
139
|
+
if (statusSource === "remote" && (newLinearStatus || newLocalStatus)) {
|
|
148
140
|
let targetStateName = newLinearStatus;
|
|
149
141
|
if (!targetStateName && newLocalStatus) {
|
|
150
142
|
targetStateName = mapLocalStatusToLinear(newLocalStatus, config);
|
|
@@ -158,12 +150,20 @@ Examples:
|
|
|
158
150
|
}
|
|
159
151
|
}
|
|
160
152
|
}
|
|
153
|
+
else if (statusSource === "local" &&
|
|
154
|
+
(newLinearStatus || newLocalStatus)) {
|
|
155
|
+
// Local mode: just note that Linear wasn't updated
|
|
156
|
+
needsSave = true;
|
|
157
|
+
}
|
|
161
158
|
// Save if anything changed
|
|
162
159
|
if (needsSave) {
|
|
163
160
|
await saveCycleData(data);
|
|
164
161
|
if (newLocalStatus && newLocalStatus !== oldLocalStatus) {
|
|
165
162
|
console.log(`Local: ${task.id} ${oldLocalStatus} → ${newLocalStatus}`);
|
|
166
163
|
}
|
|
164
|
+
if (statusSource === "local") {
|
|
165
|
+
console.log(`(Linear status not updated - use 'sync --update' to push)`);
|
|
166
|
+
}
|
|
167
167
|
}
|
|
168
168
|
else if (newLocalStatus) {
|
|
169
169
|
console.log(`Local: ${task.id} already ${newLocalStatus}`);
|
package/dist/scripts/sync.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import { getLinearClient, getPrioritySortIndex, getTeamId, loadConfig, loadCycleData, loadLocalConfig, saveConfig, saveCycleData, } from "./utils.js";
|
|
1
|
+
import { getLinearClient, getPaths, getPrioritySortIndex, getTeamId, loadConfig, loadCycleData, loadLocalConfig, saveConfig, saveCycleData, } from "./utils.js";
|
|
2
|
+
import { clearIssueImages, downloadLinearImage, ensureOutputDir, extractLinearImageUrls, isLinearImageUrl, } from "./lib/images.js";
|
|
2
3
|
async function sync() {
|
|
3
4
|
const args = process.argv.slice(2);
|
|
4
5
|
// Handle help flag
|
|
5
6
|
if (args.includes("--help") || args.includes("-h")) {
|
|
6
|
-
console.log(`Usage: ttt sync [issue-id]
|
|
7
|
+
console.log(`Usage: ttt sync [issue-id] [--update]
|
|
7
8
|
|
|
8
9
|
Sync issues from Linear to local cycle.ttt file.
|
|
9
10
|
|
|
10
11
|
Arguments:
|
|
11
12
|
issue-id Optional. Sync only this specific issue (e.g., MP-624)
|
|
12
13
|
|
|
14
|
+
Options:
|
|
15
|
+
--update Push local status changes to Linear (for local mode users)
|
|
16
|
+
This updates Linear with your local in-progress/completed statuses
|
|
17
|
+
|
|
13
18
|
What it does:
|
|
14
19
|
- Fetches active cycle from Linear
|
|
15
20
|
- Downloads all issues matching configured filters
|
|
@@ -19,15 +24,21 @@ What it does:
|
|
|
19
24
|
Examples:
|
|
20
25
|
ttt sync # Sync all matching issues
|
|
21
26
|
ttt sync MP-624 # Sync only this specific issue
|
|
27
|
+
ttt sync --update # Push local changes to Linear, then sync
|
|
22
28
|
ttt sync -d .ttt # Sync using .ttt directory`);
|
|
23
29
|
process.exit(0);
|
|
24
30
|
}
|
|
31
|
+
// Check for --update flag
|
|
32
|
+
const shouldUpdate = args.includes("--update");
|
|
25
33
|
// Parse issue ID argument (if provided)
|
|
26
34
|
const singleIssueId = args.find((arg) => !arg.startsWith("-") && arg.match(/^[A-Z]+-\d+$/i));
|
|
27
35
|
const config = await loadConfig();
|
|
28
36
|
const localConfig = await loadLocalConfig();
|
|
29
37
|
const client = getLinearClient();
|
|
30
38
|
const teamId = getTeamId(config, localConfig.team);
|
|
39
|
+
const { outputPath } = getPaths();
|
|
40
|
+
// Ensure output directory exists
|
|
41
|
+
await ensureOutputDir(outputPath);
|
|
31
42
|
// Build excluded labels set
|
|
32
43
|
const excludedLabels = new Set(localConfig.exclude_labels ?? []);
|
|
33
44
|
// Phase 1: Fetch active cycle directly from team
|
|
@@ -91,6 +102,50 @@ Examples:
|
|
|
91
102
|
const testingStateId = statusTransitions.testing
|
|
92
103
|
? stateMap.get(statusTransitions.testing)
|
|
93
104
|
: undefined;
|
|
105
|
+
const inProgressStateId = stateMap.get(statusTransitions.in_progress);
|
|
106
|
+
// Phase 2.5: Push local status changes to Linear (if --update flag)
|
|
107
|
+
if (shouldUpdate && existingData) {
|
|
108
|
+
console.log("Pushing local status changes to Linear...");
|
|
109
|
+
let pushCount = 0;
|
|
110
|
+
for (const task of existingData.tasks) {
|
|
111
|
+
// Map local status to Linear status
|
|
112
|
+
let targetStateId;
|
|
113
|
+
if (task.localStatus === "in-progress" && inProgressStateId) {
|
|
114
|
+
// Check if Linear status is not already in-progress
|
|
115
|
+
if (task.status !== statusTransitions.in_progress) {
|
|
116
|
+
targetStateId = inProgressStateId;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (task.localStatus === "completed" && testingStateId) {
|
|
120
|
+
// Check if Linear status is not already testing/done
|
|
121
|
+
const terminalStatuses = [statusTransitions.done];
|
|
122
|
+
if (statusTransitions.testing)
|
|
123
|
+
terminalStatuses.push(statusTransitions.testing);
|
|
124
|
+
if (!terminalStatuses.includes(task.status)) {
|
|
125
|
+
targetStateId = testingStateId;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (targetStateId) {
|
|
129
|
+
try {
|
|
130
|
+
await client.updateIssue(task.linearId, { stateId: targetStateId });
|
|
131
|
+
const targetName = targetStateId === inProgressStateId
|
|
132
|
+
? statusTransitions.in_progress
|
|
133
|
+
: statusTransitions.testing;
|
|
134
|
+
console.log(` ${task.id} → ${targetName}`);
|
|
135
|
+
pushCount++;
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
console.error(` Failed to update ${task.id}:`, e);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (pushCount > 0) {
|
|
143
|
+
console.log(`Pushed ${pushCount} status updates to Linear.`);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
console.log("No local changes to push.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
94
149
|
// Phase 3: Build existing tasks map for preserving local status
|
|
95
150
|
const existingTasksMap = new Map(existingData?.tasks.map((t) => [t.id, t]));
|
|
96
151
|
// Phase 4: Fetch current issues with full content
|
|
@@ -145,13 +200,49 @@ Examples:
|
|
|
145
200
|
const parent = await issue.parent;
|
|
146
201
|
const attachmentsData = await issue.attachments();
|
|
147
202
|
const commentsData = await issue.comments();
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
203
|
+
// Clear old images for this issue before downloading new ones
|
|
204
|
+
await clearIssueImages(outputPath, issue.identifier);
|
|
205
|
+
// Build attachments list and download Linear images
|
|
206
|
+
const attachments = [];
|
|
207
|
+
for (const a of attachmentsData.nodes) {
|
|
208
|
+
const attachment = {
|
|
209
|
+
id: a.id,
|
|
210
|
+
title: a.title,
|
|
211
|
+
url: a.url,
|
|
212
|
+
sourceType: a.sourceType ?? undefined,
|
|
213
|
+
};
|
|
214
|
+
// Download Linear domain images
|
|
215
|
+
if (isLinearImageUrl(a.url)) {
|
|
216
|
+
const localPath = await downloadLinearImage(a.url, issue.identifier, a.id, outputPath);
|
|
217
|
+
if (localPath) {
|
|
218
|
+
attachment.localPath = localPath;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
attachments.push(attachment);
|
|
222
|
+
}
|
|
223
|
+
// Extract and download images from description
|
|
224
|
+
if (issue.description) {
|
|
225
|
+
const descriptionImageUrls = extractLinearImageUrls(issue.description);
|
|
226
|
+
for (const url of descriptionImageUrls) {
|
|
227
|
+
// Generate a short ID from URL (last segment of path)
|
|
228
|
+
const urlPath = new URL(url).pathname;
|
|
229
|
+
const segments = urlPath.split("/").filter(Boolean);
|
|
230
|
+
const imageId = segments[segments.length - 1] || `desc_${Date.now()}`;
|
|
231
|
+
// Skip if already in attachments
|
|
232
|
+
if (attachments.some((a) => a.url === url))
|
|
233
|
+
continue;
|
|
234
|
+
const localPath = await downloadLinearImage(url, issue.identifier, imageId, outputPath);
|
|
235
|
+
if (localPath) {
|
|
236
|
+
attachments.push({
|
|
237
|
+
id: imageId,
|
|
238
|
+
title: `Description Image`,
|
|
239
|
+
url: url,
|
|
240
|
+
sourceType: "description",
|
|
241
|
+
localPath: localPath,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
155
246
|
// Build comments list
|
|
156
247
|
const comments = await Promise.all(commentsData.nodes.map(async (c) => {
|
|
157
248
|
const user = await c.user;
|
package/dist/scripts/utils.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare function getPaths(): {
|
|
|
4
4
|
configPath: string;
|
|
5
5
|
cyclePath: string;
|
|
6
6
|
localPath: string;
|
|
7
|
+
outputPath: string;
|
|
7
8
|
};
|
|
8
9
|
export interface TeamConfig {
|
|
9
10
|
id: string;
|
|
@@ -32,6 +33,7 @@ export interface StatusTransitions {
|
|
|
32
33
|
in_progress: string;
|
|
33
34
|
done: string;
|
|
34
35
|
testing?: string;
|
|
36
|
+
blocked?: string;
|
|
35
37
|
}
|
|
36
38
|
export interface Config {
|
|
37
39
|
teams: Record<string, TeamConfig>;
|
|
@@ -58,6 +60,7 @@ export interface Attachment {
|
|
|
58
60
|
title: string;
|
|
59
61
|
url: string;
|
|
60
62
|
sourceType?: string;
|
|
63
|
+
localPath?: string;
|
|
61
64
|
}
|
|
62
65
|
export interface Comment {
|
|
63
66
|
id: string;
|
|
@@ -70,7 +73,7 @@ export interface Task {
|
|
|
70
73
|
linearId: string;
|
|
71
74
|
title: string;
|
|
72
75
|
status: string;
|
|
73
|
-
localStatus: "pending" | "in-progress" | "completed" | "blocked
|
|
76
|
+
localStatus: "pending" | "in-progress" | "completed" | "blocked";
|
|
74
77
|
assignee?: string;
|
|
75
78
|
priority: number;
|
|
76
79
|
labels: string[];
|
|
@@ -92,8 +95,10 @@ export interface LocalConfig {
|
|
|
92
95
|
current_user: string;
|
|
93
96
|
team: string;
|
|
94
97
|
teams?: string[];
|
|
98
|
+
qa_pm_team?: string;
|
|
95
99
|
exclude_labels?: string[];
|
|
96
100
|
label?: string;
|
|
101
|
+
status_source?: "remote" | "local";
|
|
97
102
|
}
|
|
98
103
|
export declare function fileExists(filePath: string): Promise<boolean>;
|
|
99
104
|
export declare function loadConfig(): Promise<Config>;
|
package/dist/scripts/utils.js
CHANGED
|
@@ -19,12 +19,14 @@ const BASE_DIR = getBaseDir();
|
|
|
19
19
|
const CONFIG_PATH = path.join(BASE_DIR, "config.toon");
|
|
20
20
|
const CYCLE_PATH = path.join(BASE_DIR, "cycle.toon");
|
|
21
21
|
const LOCAL_PATH = path.join(BASE_DIR, "local.toon");
|
|
22
|
+
const OUTPUT_PATH = path.join(BASE_DIR, "output");
|
|
22
23
|
export function getPaths() {
|
|
23
24
|
return {
|
|
24
25
|
baseDir: BASE_DIR,
|
|
25
26
|
configPath: CONFIG_PATH,
|
|
26
27
|
cyclePath: CYCLE_PATH,
|
|
27
28
|
localPath: LOCAL_PATH,
|
|
29
|
+
outputPath: OUTPUT_PATH,
|
|
28
30
|
};
|
|
29
31
|
}
|
|
30
32
|
// Linear priority value to name mapping (fixed by Linear API)
|
package/dist/scripts/work-on.js
CHANGED
|
@@ -83,8 +83,11 @@ Examples:
|
|
|
83
83
|
// Mark as In Progress
|
|
84
84
|
if (task.localStatus === "pending") {
|
|
85
85
|
task.localStatus = "in-progress";
|
|
86
|
-
// Update Linear
|
|
87
|
-
|
|
86
|
+
// Update Linear (only if status_source is 'remote' or not set)
|
|
87
|
+
const statusSource = localConfig.status_source || "remote";
|
|
88
|
+
if (statusSource === "remote" &&
|
|
89
|
+
task.linearId &&
|
|
90
|
+
process.env.LINEAR_API_KEY) {
|
|
88
91
|
const transitions = getStatusTransitions(config);
|
|
89
92
|
const success = await updateIssueStatus(task.linearId, transitions.in_progress, config, localConfig.team);
|
|
90
93
|
if (success) {
|
|
@@ -94,6 +97,9 @@ Examples:
|
|
|
94
97
|
}
|
|
95
98
|
await saveCycleData(data);
|
|
96
99
|
console.log(`Local: ${task.id} → in-progress`);
|
|
100
|
+
if (statusSource === "local") {
|
|
101
|
+
console.log(`(Linear status not updated - use 'sync --update' to push)`);
|
|
102
|
+
}
|
|
97
103
|
}
|
|
98
104
|
// Display task info
|
|
99
105
|
displayTaskFull(task, "👷");
|
package/package.json
CHANGED
|
@@ -30,7 +30,17 @@ Script displays title, description, priority, labels, and attachments.
|
|
|
30
30
|
3. Implement the fix/feature
|
|
31
31
|
4. Commit with conventional format
|
|
32
32
|
|
|
33
|
-
### 4.
|
|
33
|
+
### 4. Handle Blockers (if any)
|
|
34
|
+
|
|
35
|
+
If you encounter a blocker (waiting for backend, design, external dependency):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
ttt status --set blocked
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Add a comment explaining the blocker, then move to another task.
|
|
42
|
+
|
|
43
|
+
### 5. Verify
|
|
34
44
|
|
|
35
45
|
Run project-required verification before completing:
|
|
36
46
|
|
|
@@ -39,7 +49,7 @@ Run project-required verification before completing:
|
|
|
39
49
|
# (e.g., type-check, lint, test, build)
|
|
40
50
|
```
|
|
41
51
|
|
|
42
|
-
###
|
|
52
|
+
### 6. Complete
|
|
43
53
|
|
|
44
54
|
Use `/done-job` to mark task as completed
|
|
45
55
|
|