team-toon-tack 3.7.7 → 3.9.3
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +20 -0
- package/README.md +30 -4
- package/README.zh-TW.md +30 -4
- package/commands/ttt-work-on.md +10 -57
- package/dist/bin/cli.js +1 -1
- package/dist/scripts/cancel.js +4 -1
- package/dist/scripts/create.d.ts +1 -1
- package/dist/scripts/create.js +23 -5
- package/dist/scripts/lib/adapters/linear-adapter.js +25 -33
- package/dist/scripts/lib/adapters/trello-adapter.d.ts +1 -1
- package/dist/scripts/lib/adapters/trello-adapter.js +93 -5
- package/dist/scripts/lib/adapters/types.d.ts +1 -1
- package/dist/scripts/lib/linear.d.ts +15 -0
- package/dist/scripts/lib/linear.js +26 -1
- package/dist/scripts/show.js +144 -117
- package/dist/scripts/sync.js +23 -10
- package/dist/scripts/work-on.js +1 -1
- package/package.json +2 -1
- package/skills/managing-linear-tasks/SKILL.md +33 -32
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "team-toon-tack",
|
|
3
|
+
"version": "3.9.1",
|
|
4
|
+
"description": "透過 ttt CLI 同步與管理 Linear、Trello 任務。",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "wayne930242"
|
|
7
|
+
},
|
|
8
|
+
"repository": "https://github.com/wayne930242/team-toon-tack",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"skills": "./skills/",
|
|
11
|
+
"interface": {
|
|
12
|
+
"displayName": "team-toon-tack",
|
|
13
|
+
"shortDescription": "同步與管理 Linear、Trello 任務",
|
|
14
|
+
"longDescription": "透過 ttt CLI 同步任務、查詢狀態、記錄估時,並依使用者要求更新 Linear 與 Trello。",
|
|
15
|
+
"developerName": "wayne930242",
|
|
16
|
+
"category": "Productivity",
|
|
17
|
+
"capabilities": ["Interactive", "Write"],
|
|
18
|
+
"defaultPrompt": ["使用 ttt 同步並列出我的任務。"]
|
|
19
|
+
}
|
|
20
|
+
}
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Optimized task workflow for Claude Code — supports Linear and Trello, saves si
|
|
|
8
8
|
|
|
9
9
|
- **Token Efficient** — Local cycle cache eliminates repeated API calls, saving significant tokens vs MCP
|
|
10
10
|
- **Multi-source Support** — Works with both Linear and Trello
|
|
11
|
-
- **Smart Task Selection** — Auto-pick highest priority
|
|
11
|
+
- **Smart Task Selection** — Auto-pick your highest priority pending task with `/work-on next`
|
|
12
12
|
- **Multi-team Support** — Sync and filter issues across multiple teams/boards
|
|
13
13
|
- **Flexible Sync Modes** — Choose between remote (immediate sync) or local (offline-first, sync later with `--update`)
|
|
14
14
|
- **Completion Modes** — Four modes for task completion (Linear): simple, strict review, upstream strict, upstream not strict
|
|
@@ -67,9 +67,32 @@ During init, you'll be prompted to select your task source (Linear or Trello) an
|
|
|
67
67
|
|
|
68
68
|
```
|
|
69
69
|
/plugin marketplace add wayne930242/team-toon-tack
|
|
70
|
-
/plugin install team-toon-tack@
|
|
70
|
+
/plugin install team-toon-tack@ttt-marketplace
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
### Codex plugin 安裝(選擇性)
|
|
74
|
+
|
|
75
|
+
先依上方步驟安裝 `ttt` CLI 並執行 `ttt init`。Plugin 提供任務管理技能,透過 shell 呼叫 CLI。
|
|
76
|
+
|
|
77
|
+
從 GitHub 安裝:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
codex plugin marketplace add wayne930242/team-toon-tack
|
|
81
|
+
codex plugin add team-toon-tack@ttt-marketplace
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
從本地 checkout 安裝,在此 repository 根目錄執行:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
codex plugin marketplace add "$PWD"
|
|
88
|
+
codex plugin add team-toon-tack@ttt-marketplace
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
若已註冊同名的 GitHub marketplace,先執行 `codex plugin marketplace remove ttt-marketplace`,再加入本地路徑。
|
|
92
|
+
修改已安裝的同版本內容後,先執行 `codex plugin remove team-toon-tack@ttt-marketplace`,再執行上述 `codex plugin add` 重新安裝。
|
|
93
|
+
|
|
94
|
+
安裝完成後開啟新的 Codex 對話,使用 `$managing-linear-tasks`,或直接說「使用 ttt 同步並列出我的任務」。Codex 會使用 `ttt sync`、`ttt show` 等 CLI 指令。
|
|
95
|
+
|
|
73
96
|
### 3. Daily Workflow
|
|
74
97
|
|
|
75
98
|
In Claude Code (with plugin installed):
|
|
@@ -111,6 +134,9 @@ ttt init --force # Overwrite existing config
|
|
|
111
134
|
|
|
112
135
|
Sync current cycle issues from Linear/Trello.
|
|
113
136
|
|
|
137
|
+
Only issues assigned to `current_user` are synced. Leave `current_user` empty in
|
|
138
|
+
`.ttt/local.toon` for a team-wide sync.
|
|
139
|
+
|
|
114
140
|
```bash
|
|
115
141
|
ttt sync # Sync Todo/In Progress issues (fast)
|
|
116
142
|
ttt sync --all # Sync all issues regardless of status
|
|
@@ -234,7 +260,7 @@ Install the plugin for Claude Code integration:
|
|
|
234
260
|
|
|
235
261
|
```
|
|
236
262
|
/plugin marketplace add wayne930242/team-toon-tack
|
|
237
|
-
/plugin install team-toon-tack@
|
|
263
|
+
/plugin install team-toon-tack@ttt-marketplace
|
|
238
264
|
```
|
|
239
265
|
|
|
240
266
|
### Available Commands
|
|
@@ -250,7 +276,7 @@ Install the plugin for Claude Code integration:
|
|
|
250
276
|
|
|
251
277
|
### Auto-Activated Skill
|
|
252
278
|
|
|
253
|
-
The plugin includes a `linear-
|
|
279
|
+
The plugin includes a `managing-linear-tasks` skill that automatically activates when working with tasks, providing workflow guidance and best practices.
|
|
254
280
|
|
|
255
281
|
## License
|
|
256
282
|
|
package/README.zh-TW.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
- **節省 Token** — 本地 cycle 快取避免重複 API 呼叫,比 MCP 省下大量 token
|
|
10
10
|
- **多來源支援** — 支援 Linear 和 Trello
|
|
11
|
-
- **智慧任務挑選** — `/work-on next`
|
|
11
|
+
- **智慧任務挑選** — `/work-on next` 自動選擇指派給你的最高優先級待辦任務
|
|
12
12
|
- **多團隊支援** — 跨多個團隊/看板同步與過濾 issue
|
|
13
13
|
- **彈性同步模式** — 選擇 remote(即時同步)或 local(離線優先,稍後用 `--update` 同步)
|
|
14
14
|
- **完成模式** — 四種任務完成模式(Linear):簡單、嚴格審查、上下游嚴格、上下游非嚴格
|
|
@@ -67,9 +67,32 @@ ttt init
|
|
|
67
67
|
|
|
68
68
|
```
|
|
69
69
|
/plugin marketplace add wayne930242/team-toon-tack
|
|
70
|
-
/plugin install team-toon-tack@
|
|
70
|
+
/plugin install team-toon-tack@ttt-marketplace
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
### Codex plugin 安裝(選擇性)
|
|
74
|
+
|
|
75
|
+
先依上方步驟安裝 `ttt` CLI 並執行 `ttt init`。Plugin 提供任務管理技能,透過 shell 呼叫 CLI。
|
|
76
|
+
|
|
77
|
+
從 GitHub 安裝:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
codex plugin marketplace add wayne930242/team-toon-tack
|
|
81
|
+
codex plugin add team-toon-tack@ttt-marketplace
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
從本地 checkout 安裝,在此 repository 根目錄執行:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
codex plugin marketplace add "$PWD"
|
|
88
|
+
codex plugin add team-toon-tack@ttt-marketplace
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
若已註冊同名的 GitHub marketplace,先執行 `codex plugin marketplace remove ttt-marketplace`,再加入本地路徑。
|
|
92
|
+
修改已安裝的同版本內容後,先執行 `codex plugin remove team-toon-tack@ttt-marketplace`,再執行上述 `codex plugin add` 重新安裝。
|
|
93
|
+
|
|
94
|
+
安裝完成後開啟新的 Codex 對話,使用 `$managing-linear-tasks`,或直接說「使用 ttt 同步並列出我的任務」。Codex 會使用 `ttt sync`、`ttt show` 等 CLI 指令。
|
|
95
|
+
|
|
73
96
|
### 3. 每日工作流
|
|
74
97
|
|
|
75
98
|
在 Claude Code 中(安裝 plugin 後):
|
|
@@ -111,6 +134,9 @@ ttt init --force # 覆蓋現有配置
|
|
|
111
134
|
|
|
112
135
|
從 Linear/Trello 同步當前 cycle 的 issue。
|
|
113
136
|
|
|
137
|
+
只同步指派給 `current_user` 的 issue。若要同步整個團隊,把 `.ttt/local.toon` 的
|
|
138
|
+
`current_user` 留空。
|
|
139
|
+
|
|
114
140
|
```bash
|
|
115
141
|
ttt sync # 同步 Todo/In Progress 狀態的 issue(較快)
|
|
116
142
|
ttt sync --all # 同步所有狀態的 issue
|
|
@@ -234,7 +260,7 @@ your-project/
|
|
|
234
260
|
|
|
235
261
|
```
|
|
236
262
|
/plugin marketplace add wayne930242/team-toon-tack
|
|
237
|
-
/plugin install team-toon-tack@
|
|
263
|
+
/plugin install team-toon-tack@ttt-marketplace
|
|
238
264
|
```
|
|
239
265
|
|
|
240
266
|
### 可用指令
|
|
@@ -250,7 +276,7 @@ your-project/
|
|
|
250
276
|
|
|
251
277
|
### 自動啟用技能
|
|
252
278
|
|
|
253
|
-
Plugin 包含 `linear-
|
|
279
|
+
Plugin 包含 `managing-linear-tasks` 技能,在處理任務時會自動啟用,提供工作流程指導和最佳實踐。
|
|
254
280
|
|
|
255
281
|
## 授權
|
|
256
282
|
|
package/commands/ttt-work-on.md
CHANGED
|
@@ -5,7 +5,6 @@ arguments:
|
|
|
5
5
|
- name: issue-id
|
|
6
6
|
description: "Issue ID (e.g., MP-624) or 'next' for auto-select. Defaults to 'next'."
|
|
7
7
|
required: false
|
|
8
|
-
default: next
|
|
9
8
|
- name: dry-run
|
|
10
9
|
description: Preview selection without changing status
|
|
11
10
|
required: false
|
|
@@ -48,67 +47,21 @@ Options:
|
|
|
48
47
|
--dry-run Pick task without changing status (preview only)
|
|
49
48
|
```
|
|
50
49
|
|
|
51
|
-
##
|
|
50
|
+
## After Selection — Delegate the Implementation
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
Skipping phases is not "pragmatic", it is debt.
|
|
52
|
+
This command picks the task. The implementation workflow belongs to the user.
|
|
55
53
|
|
|
56
|
-
|
|
54
|
+
**Use the user's own workflow whenever one exists** — routing or laws in their root `CLAUDE.md`, the project `CLAUDE.md`, or a `work-on` / `start-work` skill. Follow it as written and add nothing.
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
If unclear whether to branch or what name to use, ask the user instead of assuming.
|
|
56
|
+
**Only when none exists**, run this minimal loop:
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
Scope decides depth.
|
|
68
|
-
|
|
69
|
-
- Unclear requirements or 3+ files touched → if your environment has a brainstorming/planning skill, use it; otherwise clarify scope with the user and write a short plan before coding.
|
|
70
|
-
- Clear and small (≤2 files, obvious change) → state a 2–3 bullet plan inline before coding.
|
|
71
|
-
|
|
72
|
-
Never go straight to code.
|
|
73
|
-
Planning collapses ambiguity before implementation.
|
|
74
|
-
|
|
75
|
-
### 3. Test First (TDD)
|
|
76
|
-
|
|
77
|
-
Red → Green → Refactor per behavior:
|
|
78
|
-
|
|
79
|
-
1. Write one failing test naming the behavior.
|
|
80
|
-
2. Run test — verify it fails for the expected reason.
|
|
81
|
-
3. Write minimal code to pass.
|
|
82
|
-
4. Run test — verify pass, other tests still green.
|
|
83
|
-
5. Refactor if needed, keep green.
|
|
84
|
-
|
|
85
|
-
No production code without a failing test first.
|
|
86
|
-
|
|
87
|
-
### 4. Review (before `/ttt:done`)
|
|
88
|
-
|
|
89
|
-
Verify before claiming completion — run and confirm output:
|
|
90
|
-
|
|
91
|
-
- Full test suite passes.
|
|
92
|
-
- `npm run lint` / `npm run type` clean (or project-specific commands).
|
|
93
|
-
- Only requested scope changed — no drive-by edits.
|
|
94
|
-
- No debug logs, stubs, TODOs.
|
|
95
|
-
|
|
96
|
-
Evidence before assertions.
|
|
97
|
-
Do not claim complete without running the commands.
|
|
98
|
-
|
|
99
|
-
### 5. Complete
|
|
100
|
-
|
|
101
|
-
```text
|
|
102
|
-
/ttt:done -m "summary"
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
A task is NOT complete until `/ttt:done` runs.
|
|
106
|
-
This is MANDATORY.
|
|
107
|
-
|
|
108
|
-
## Project-Specific Skill
|
|
58
|
+
- Branch using the repo's existing naming convention.
|
|
59
|
+
- State a short plan before coding; for unclear scope or 3+ files, settle the scope with the user first.
|
|
60
|
+
- Write the failing test first, then the minimal code to pass it.
|
|
61
|
+
- Run lint / type / test and report the real output before claiming completion.
|
|
62
|
+
- Offer `/ttt:write-work-on-skill` to capture this project's commands as a reusable skill.
|
|
109
63
|
|
|
110
|
-
|
|
111
|
-
If missing, suggest `/ttt:write-work-on-skill` — it scaffolds a skill following this same 4-phase structure.
|
|
64
|
+
Either path closes the task with `/ttt:done -m "summary"`.
|
|
112
65
|
|
|
113
66
|
## Error Handling
|
|
114
67
|
|
package/dist/bin/cli.js
CHANGED
|
@@ -172,7 +172,7 @@ async function main() {
|
|
|
172
172
|
break;
|
|
173
173
|
case "create":
|
|
174
174
|
process.argv = ["node", "create.js", ...commandArgs];
|
|
175
|
-
await importScript("create.js");
|
|
175
|
+
await importScript("create.js").then((module) => module.create());
|
|
176
176
|
break;
|
|
177
177
|
case "assign":
|
|
178
178
|
process.argv = ["node", "assign.js", ...commandArgs];
|
package/dist/scripts/cancel.js
CHANGED
package/dist/scripts/create.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
export
|
|
2
|
+
export declare function create(args?: string[]): Promise<void>;
|
package/dist/scripts/create.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
2
4
|
import { input, select } from "@inquirer/prompts";
|
|
3
5
|
import { createAdapter } from "./lib/adapters/index.js";
|
|
4
6
|
import { getSourceType, getTeamId, loadConfig, loadCycleData, loadLocalConfig, sourceIssueToTask, upsertTaskInCycleData, } from "./utils.js";
|
|
@@ -121,8 +123,7 @@ async function resolveParentSourceId(adapter, parentIdentifier) {
|
|
|
121
123
|
}
|
|
122
124
|
return parent.sourceId;
|
|
123
125
|
}
|
|
124
|
-
async function create() {
|
|
125
|
-
const args = process.argv.slice(2);
|
|
126
|
+
export async function create(args = process.argv.slice(2)) {
|
|
126
127
|
if (args.includes("--help") || args.includes("-h")) {
|
|
127
128
|
console.log(`Usage: ttt create [options]
|
|
128
129
|
|
|
@@ -156,6 +157,10 @@ Examples:
|
|
|
156
157
|
const labelArg = parsed.label;
|
|
157
158
|
const statusName = parsed.status;
|
|
158
159
|
const parentId = parsed.parent;
|
|
160
|
+
if (sourceType === "trello" && parentId) {
|
|
161
|
+
console.error("Parent issues are not supported for Trello cards.");
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
159
164
|
if (!title) {
|
|
160
165
|
if (!parsed.interactive) {
|
|
161
166
|
console.error("Title is required when --no-interactive is set.");
|
|
@@ -204,8 +209,13 @@ Examples:
|
|
|
204
209
|
}
|
|
205
210
|
console.log("Creating issue...");
|
|
206
211
|
const adapter = createAdapter(config);
|
|
212
|
+
let cycleData = sourceType === "trello" ? await loadCycleData() : null;
|
|
213
|
+
if (sourceType === "trello" && !cycleData) {
|
|
214
|
+
console.error("No cycle data found. Run ttt sync before creating a card.");
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
207
217
|
const assigneeId = resolveAssigneeId(config, assigneeKey);
|
|
208
|
-
const statusId = await resolveStatusId(config, adapter, teamId, statusName);
|
|
218
|
+
const statusId = await resolveStatusId(config, adapter, teamId, sourceType === "trello" && !statusName ? "todo" : statusName);
|
|
209
219
|
const labelIds = resolveLabelIds(config, labelArg);
|
|
210
220
|
const parentSourceId = await resolveParentSourceId(adapter, parentId);
|
|
211
221
|
const currentCycle = await adapter.getCurrentCycle(teamId);
|
|
@@ -229,11 +239,19 @@ Examples:
|
|
|
229
239
|
console.log(`\n✅ Created ${issue.id}: ${issue.title}`);
|
|
230
240
|
if (issue.url)
|
|
231
241
|
console.log(` ${issue.url}`);
|
|
232
|
-
|
|
242
|
+
if (sourceType !== "trello")
|
|
243
|
+
cycleData = await loadCycleData();
|
|
233
244
|
if (cycleData) {
|
|
234
245
|
const task = sourceIssueToTask(issue, sourceType);
|
|
235
246
|
await upsertTaskInCycleData(task, cycleData);
|
|
236
247
|
console.log(` Added to local cycle data.`);
|
|
237
248
|
}
|
|
238
249
|
}
|
|
239
|
-
|
|
250
|
+
const entryPath = process.argv[1];
|
|
251
|
+
if (entryPath &&
|
|
252
|
+
import.meta.url === pathToFileURL(path.resolve(entryPath)).href) {
|
|
253
|
+
create().catch((error) => {
|
|
254
|
+
console.error("Failed to create issue:", error instanceof Error ? error.message : error);
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Wraps the @linear/sdk to implement TaskSourceAdapter interface
|
|
4
4
|
*/
|
|
5
5
|
import { LinearClient } from "@linear/sdk";
|
|
6
|
+
import { fetchIssuesPaged, MAX_FETCHED_ISSUES } from "../linear.js";
|
|
6
7
|
export class LinearAdapter {
|
|
7
8
|
type = "linear";
|
|
8
9
|
client;
|
|
@@ -88,42 +89,26 @@ export class LinearAdapter {
|
|
|
88
89
|
if (options.labelNames && options.labelNames.length > 0) {
|
|
89
90
|
filter.labels = { name: { in: options.labelNames } };
|
|
90
91
|
}
|
|
91
|
-
if (options.
|
|
92
|
-
filter.assignee = { email: {
|
|
92
|
+
if (options.assigneeEmails && options.assigneeEmails.length > 0) {
|
|
93
|
+
filter.assignee = { email: { in: options.assigneeEmails } };
|
|
93
94
|
}
|
|
94
|
-
const
|
|
95
|
-
filter,
|
|
96
|
-
first: options.limit ?? 50,
|
|
97
|
-
});
|
|
95
|
+
const page = await fetchIssuesPaged(this.client, filter, "fetch issues", options.limit ?? MAX_FETCHED_ISSUES);
|
|
98
96
|
const issues = [];
|
|
99
97
|
const excludeLabels = new Set(options.excludeLabels ?? []);
|
|
100
|
-
for (const issue of
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const labels = await
|
|
98
|
+
for (const issue of page.nodes) {
|
|
99
|
+
// Independent relations - awaiting them in sequence turns a list of
|
|
100
|
+
// 200 issues into 800 serial round-trips.
|
|
101
|
+
const [state, assignee, labels, parent] = await Promise.all([
|
|
102
|
+
issue.state,
|
|
103
|
+
issue.assignee,
|
|
104
|
+
issue.labels(),
|
|
105
|
+
issue.parent,
|
|
106
|
+
]);
|
|
104
107
|
const labelNames = labels.nodes.map((l) => l.name);
|
|
105
108
|
// Skip if any label is in excluded list
|
|
106
109
|
if (labelNames.some((name) => excludeLabels.has(name))) {
|
|
107
110
|
continue;
|
|
108
111
|
}
|
|
109
|
-
const parent = await issue.parent;
|
|
110
|
-
const attachmentsData = await issue.attachments();
|
|
111
|
-
const commentsData = await issue.comments();
|
|
112
|
-
const attachments = attachmentsData.nodes.map((a) => ({
|
|
113
|
-
id: a.id,
|
|
114
|
-
title: a.title,
|
|
115
|
-
url: a.url,
|
|
116
|
-
sourceType: a.sourceType ?? undefined,
|
|
117
|
-
}));
|
|
118
|
-
const comments = await Promise.all(commentsData.nodes.map(async (c) => {
|
|
119
|
-
const user = await c.user;
|
|
120
|
-
return {
|
|
121
|
-
id: c.id,
|
|
122
|
-
body: c.body,
|
|
123
|
-
createdAt: c.createdAt.toISOString(),
|
|
124
|
-
user: user?.displayName ?? user?.email,
|
|
125
|
-
};
|
|
126
|
-
}));
|
|
127
112
|
issues.push({
|
|
128
113
|
id: issue.identifier,
|
|
129
114
|
sourceId: issue.id,
|
|
@@ -138,8 +123,8 @@ export class LinearAdapter {
|
|
|
138
123
|
url: issue.url,
|
|
139
124
|
parentIssueId: parent?.identifier,
|
|
140
125
|
branchName: issue.branchName,
|
|
141
|
-
attachments:
|
|
142
|
-
comments:
|
|
126
|
+
attachments: undefined, // Loaded separately via getIssue()
|
|
127
|
+
comments: undefined, // Loaded separately via getIssue()
|
|
143
128
|
});
|
|
144
129
|
}
|
|
145
130
|
return issues;
|
|
@@ -309,18 +294,25 @@ export class LinearAdapter {
|
|
|
309
294
|
}
|
|
310
295
|
async cancelIssue(sourceId) {
|
|
311
296
|
try {
|
|
312
|
-
//
|
|
297
|
+
// Resolve by statusType, not display name -- teams name this state
|
|
298
|
+
// "Canceled", "Cancelled", "Done - Cancelled", etc.
|
|
313
299
|
const issue = await this.client.issue(sourceId);
|
|
314
300
|
const team = await issue.team;
|
|
315
301
|
if (!team) {
|
|
316
302
|
return { success: false, error: "Could not determine issue team" };
|
|
317
303
|
}
|
|
318
304
|
const states = await this.client.workflowStates({
|
|
319
|
-
filter: {
|
|
305
|
+
filter: {
|
|
306
|
+
team: { id: { eq: team.id } },
|
|
307
|
+
type: { in: ["canceled", "cancelled"] },
|
|
308
|
+
},
|
|
320
309
|
});
|
|
321
310
|
const cancelledState = states.nodes[0];
|
|
322
311
|
if (!cancelledState) {
|
|
323
|
-
return {
|
|
312
|
+
return {
|
|
313
|
+
success: false,
|
|
314
|
+
error: `No cancelled-type workflow state found for team ${team.id}`,
|
|
315
|
+
};
|
|
324
316
|
}
|
|
325
317
|
const payload = await this.client.updateIssue(sourceId, {
|
|
326
318
|
stateId: cancelledState.id,
|
|
@@ -27,7 +27,7 @@ export declare class TrelloAdapter implements TaskSourceAdapter {
|
|
|
27
27
|
error?: string;
|
|
28
28
|
}>;
|
|
29
29
|
getInitData(teamId?: string): Promise<InitData>;
|
|
30
|
-
createIssue(
|
|
30
|
+
createIssue(options: CreateIssueOptions): Promise<{
|
|
31
31
|
success: boolean;
|
|
32
32
|
issue?: SourceIssue;
|
|
33
33
|
error?: string;
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { TrelloClient } from "../trello.js";
|
|
6
6
|
import { detectPriorityFromLabels, } from "./types.js";
|
|
7
|
+
const PRIORITY_NAMES = {
|
|
8
|
+
1: "urgent",
|
|
9
|
+
2: "high",
|
|
10
|
+
3: "medium",
|
|
11
|
+
4: "low",
|
|
12
|
+
};
|
|
7
13
|
export class TrelloAdapter {
|
|
8
14
|
type = "trello";
|
|
9
15
|
client;
|
|
@@ -103,6 +109,14 @@ export class TrelloAdapter {
|
|
|
103
109
|
// Get first assignee
|
|
104
110
|
const firstMemberId = card.idMembers[0];
|
|
105
111
|
const assignee = firstMemberId ? memberMap.get(firstMemberId) : undefined;
|
|
112
|
+
// Filter by assignee if specified (OR logic)
|
|
113
|
+
if (options.assigneeEmails && options.assigneeEmails.length > 0) {
|
|
114
|
+
const email = assignee?.email?.toLowerCase();
|
|
115
|
+
if (!email ||
|
|
116
|
+
!options.assigneeEmails.some((e) => e.toLowerCase() === email)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
106
120
|
// Detect priority from labels
|
|
107
121
|
const priority = detectPriorityFromLabels(labelNames);
|
|
108
122
|
issues.push({
|
|
@@ -270,11 +284,85 @@ export class TrelloAdapter {
|
|
|
270
284
|
currentCycle: undefined, // Trello has no cycle
|
|
271
285
|
};
|
|
272
286
|
}
|
|
273
|
-
async createIssue(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
287
|
+
async createIssue(options) {
|
|
288
|
+
try {
|
|
289
|
+
const statuses = await this.getStatuses(options.teamId);
|
|
290
|
+
const targetStatus = options.statusId
|
|
291
|
+
? statuses.find((status) => status.id === options.statusId)
|
|
292
|
+
: statuses[0];
|
|
293
|
+
if (!targetStatus) {
|
|
294
|
+
return {
|
|
295
|
+
success: false,
|
|
296
|
+
error: options.statusId
|
|
297
|
+
? `Trello list ${options.statusId} is not open on board ${options.teamId}`
|
|
298
|
+
: `Trello board ${options.teamId} has no open lists`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const labelIds = new Set(options.labelIds ?? []);
|
|
302
|
+
let knownLabels = [];
|
|
303
|
+
if (labelIds.size > 0 || (options.priority ?? 0) > 0) {
|
|
304
|
+
knownLabels = await this.getLabels(options.teamId);
|
|
305
|
+
}
|
|
306
|
+
if (options.priority && options.priority > 0) {
|
|
307
|
+
const priorityLabel = knownLabels.find((label) => detectPriorityFromLabels([label.name]) === options.priority);
|
|
308
|
+
if (!priorityLabel) {
|
|
309
|
+
return {
|
|
310
|
+
success: false,
|
|
311
|
+
error: `Trello board ${options.teamId} has no label matching priority "${PRIORITY_NAMES[options.priority]}"`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
labelIds.add(priorityLabel.id);
|
|
315
|
+
}
|
|
316
|
+
const card = await this.client.createCard({
|
|
317
|
+
name: options.title,
|
|
318
|
+
idList: targetStatus.id,
|
|
319
|
+
desc: options.description,
|
|
320
|
+
idMembers: options.assigneeId ? [options.assigneeId] : undefined,
|
|
321
|
+
idLabels: labelIds.size > 0 ? [...labelIds] : undefined,
|
|
322
|
+
});
|
|
323
|
+
if (!card.id || !card.shortLink || !card.name || !card.idList) {
|
|
324
|
+
return {
|
|
325
|
+
success: false,
|
|
326
|
+
error: "Trello created a card but returned an incomplete response",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const labelNames = Array.isArray(card.labels)
|
|
330
|
+
? card.labels.map((label) => label.name).filter(Boolean)
|
|
331
|
+
: knownLabels
|
|
332
|
+
.filter((label) => labelIds.has(label.id))
|
|
333
|
+
.map((label) => label.name);
|
|
334
|
+
const status = statuses.find((candidate) => candidate.id === card.idList)?.name ??
|
|
335
|
+
targetStatus.name;
|
|
336
|
+
const assigneeId = Array.isArray(card.idMembers)
|
|
337
|
+
? card.idMembers[0]
|
|
338
|
+
: options.assigneeId;
|
|
339
|
+
return {
|
|
340
|
+
success: true,
|
|
341
|
+
issue: {
|
|
342
|
+
id: card.shortLink,
|
|
343
|
+
sourceId: card.id,
|
|
344
|
+
title: card.name,
|
|
345
|
+
description: card.desc || undefined,
|
|
346
|
+
status,
|
|
347
|
+
statusId: card.idList,
|
|
348
|
+
assigneeId,
|
|
349
|
+
assigneeEmail: undefined,
|
|
350
|
+
priority: detectPriorityFromLabels(labelNames),
|
|
351
|
+
labels: labelNames,
|
|
352
|
+
url: card.url,
|
|
353
|
+
parentIssueId: undefined,
|
|
354
|
+
branchName: undefined,
|
|
355
|
+
attachments: undefined,
|
|
356
|
+
comments: undefined,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
return {
|
|
362
|
+
success: false,
|
|
363
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
278
366
|
}
|
|
279
367
|
async updateIssue(_sourceId, _fields) {
|
|
280
368
|
return {
|
|
@@ -1,4 +1,19 @@
|
|
|
1
|
+
import type { Issue, LinearClient } from "@linear/sdk";
|
|
1
2
|
import { type Config, type StatusTransitions } from "../utils.js";
|
|
3
|
+
/** Issues requested per Linear API page. */
|
|
4
|
+
export declare const ISSUE_PAGE_SIZE = 100;
|
|
5
|
+
/** Hard stop so a broad filter can't pull an entire backlog. */
|
|
6
|
+
export declare const MAX_FETCHED_ISSUES = 500;
|
|
7
|
+
/**
|
|
8
|
+
* Fetch every issue matching `filter`, following pagination. Without this a
|
|
9
|
+
* single `first: n` request silently returns whichever page Linear hands back
|
|
10
|
+
* first, dropping the rest with no signal. `truncated` reports that `cap` cut
|
|
11
|
+
* the result short.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fetchIssuesPaged(client: LinearClient, filter: Record<string, unknown>, label: string, cap?: number): Promise<{
|
|
14
|
+
nodes: Issue[];
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
}>;
|
|
2
17
|
export interface WorkflowStateInfo {
|
|
3
18
|
id: string;
|
|
4
19
|
name: string;
|
|
@@ -1,5 +1,30 @@
|
|
|
1
|
-
import { getLinearClient, getTeamId, } from "../utils.js";
|
|
1
|
+
import { getLinearClient, getTeamId, withRetry, } from "../utils.js";
|
|
2
2
|
import { getFirstTodoStatus } from "./status-helpers.js";
|
|
3
|
+
/** Issues requested per Linear API page. */
|
|
4
|
+
export const ISSUE_PAGE_SIZE = 100;
|
|
5
|
+
/** Hard stop so a broad filter can't pull an entire backlog. */
|
|
6
|
+
export const MAX_FETCHED_ISSUES = 500;
|
|
7
|
+
/**
|
|
8
|
+
* Fetch every issue matching `filter`, following pagination. Without this a
|
|
9
|
+
* single `first: n` request silently returns whichever page Linear hands back
|
|
10
|
+
* first, dropping the rest with no signal. `truncated` reports that `cap` cut
|
|
11
|
+
* the result short.
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchIssuesPaged(client, filter, label, cap = MAX_FETCHED_ISSUES) {
|
|
14
|
+
const connection = await withRetry(() => client.issues({ filter, first: ISSUE_PAGE_SIZE }), { label });
|
|
15
|
+
while (connection.pageInfo.hasNextPage && connection.nodes.length < cap) {
|
|
16
|
+
const before = connection.nodes.length;
|
|
17
|
+
await withRetry(() => connection.fetchNext(), {
|
|
18
|
+
label: `${label} (next page)`,
|
|
19
|
+
});
|
|
20
|
+
if (connection.nodes.length === before)
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
nodes: connection.nodes.slice(0, cap),
|
|
25
|
+
truncated: connection.nodes.length > cap || connection.pageInfo.hasNextPage,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
3
28
|
export async function getWorkflowStates(config, teamKey) {
|
|
4
29
|
const client = getLinearClient();
|
|
5
30
|
const teamId = getTeamId(config, teamKey);
|
package/dist/scripts/show.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createAdapter } from "./lib/adapters/index.js";
|
|
2
2
|
import { displayTaskFull, getStatusIcon, PRIORITY_LABELS, } from "./lib/display.js";
|
|
3
|
-
import {
|
|
3
|
+
import { getStatusTransitions } from "./lib/linear.js";
|
|
4
|
+
import { getSyncStatuses } from "./lib/status-helpers.js";
|
|
5
|
+
import { getSourceType, getUserEmails, loadConfig, loadCycleData, loadLocalConfig, } from "./utils.js";
|
|
4
6
|
function taskToMarkdown(task) {
|
|
5
7
|
const lines = [];
|
|
6
8
|
const priority = PRIORITY_LABELS[task.priority] || "None";
|
|
@@ -132,7 +134,68 @@ async function fetchIssueFromRemote(issueId) {
|
|
|
132
134
|
})),
|
|
133
135
|
};
|
|
134
136
|
}
|
|
135
|
-
|
|
137
|
+
function resolveAssigneeScope(requested, userEmails) {
|
|
138
|
+
const emails = userEmails.map((e) => e.toLowerCase());
|
|
139
|
+
const value = requested?.toLowerCase();
|
|
140
|
+
if (!value || value === "me") {
|
|
141
|
+
return emails.length > 0 ? { kind: "user", emails } : { kind: "any" };
|
|
142
|
+
}
|
|
143
|
+
if (value === "unassigned") {
|
|
144
|
+
return { kind: "unassigned" };
|
|
145
|
+
}
|
|
146
|
+
return { kind: "match", needle: value };
|
|
147
|
+
}
|
|
148
|
+
/** True when the scope reaches outside what `ttt sync` stored locally. */
|
|
149
|
+
function scopeEscapesLocalCache(scope, userEmails) {
|
|
150
|
+
return userEmails.length > 0 && scope.kind !== "user";
|
|
151
|
+
}
|
|
152
|
+
function matchesAssignee(task, scope) {
|
|
153
|
+
switch (scope.kind) {
|
|
154
|
+
case "user":
|
|
155
|
+
return (!!task.assignee && scope.emails.includes(task.assignee.toLowerCase()));
|
|
156
|
+
case "unassigned":
|
|
157
|
+
return !task.assignee;
|
|
158
|
+
case "match":
|
|
159
|
+
return !!task.assignee?.toLowerCase().includes(scope.needle);
|
|
160
|
+
case "any":
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Shared by both search paths so local and --remote filter identically. */
|
|
165
|
+
function applyFilters(tasks, filters, scope) {
|
|
166
|
+
return tasks.filter((task) => {
|
|
167
|
+
if (filters.label) {
|
|
168
|
+
const needle = filters.label.toLowerCase();
|
|
169
|
+
if (!task.labels.some((l) => l.toLowerCase().includes(needle))) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (filters.status) {
|
|
174
|
+
if (!task.status.toLowerCase().includes(filters.status.toLowerCase())) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!matchesAssignee(task, scope)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
if (filters.priority !== undefined && task.priority !== filters.priority) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Expand a substring into the exact names the API filter needs. Names come
|
|
189
|
+
* from the source itself, not config, so a label added since `ttt init` is
|
|
190
|
+
* still found. Returns null when the substring matches nothing, meaning no
|
|
191
|
+
* issue can satisfy the filter.
|
|
192
|
+
*/
|
|
193
|
+
function expandNames(needle, available) {
|
|
194
|
+
const lower = needle.toLowerCase();
|
|
195
|
+
const matches = available.filter((name) => name.toLowerCase().includes(lower));
|
|
196
|
+
return matches.length > 0 ? matches : null;
|
|
197
|
+
}
|
|
198
|
+
async function searchIssuesFromRemote(filters, scope) {
|
|
136
199
|
const config = await loadConfig();
|
|
137
200
|
const localConfig = await loadLocalConfig();
|
|
138
201
|
const adapter = createAdapter(config);
|
|
@@ -142,104 +205,61 @@ async function searchIssuesFromRemote(filters) {
|
|
|
142
205
|
console.error(`Team "${localConfig.team}" not found in config.`);
|
|
143
206
|
return [];
|
|
144
207
|
}
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (filters.assignee) {
|
|
157
|
-
const assigneeLower = filters.assignee.toLowerCase();
|
|
158
|
-
if (assigneeLower === "me") {
|
|
159
|
-
const userKeys = Array.isArray(localConfig.current_user)
|
|
160
|
-
? localConfig.current_user
|
|
161
|
-
: localConfig.current_user
|
|
162
|
-
? [localConfig.current_user]
|
|
163
|
-
: [];
|
|
164
|
-
const userEmails = userKeys
|
|
165
|
-
.map((key) => config.users[key]?.email?.toLowerCase())
|
|
166
|
-
.filter((e) => !!e);
|
|
167
|
-
if (userEmails.length === 0 ||
|
|
168
|
-
!userEmails.includes(issue.assigneeEmail?.toLowerCase() ?? "")) {
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
else if (assigneeLower === "unassigned") {
|
|
173
|
-
if (issue.assigneeEmail) {
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
else {
|
|
178
|
-
if (!issue.assigneeEmail?.toLowerCase().includes(assigneeLower)) {
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
if (filters.priority !== undefined && issue.priority !== filters.priority) {
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
const task = {
|
|
187
|
-
id: issue.id,
|
|
188
|
-
linearId: issue.sourceId,
|
|
189
|
-
sourceId: issue.sourceId,
|
|
190
|
-
sourceType,
|
|
191
|
-
title: issue.title,
|
|
192
|
-
status: issue.status,
|
|
193
|
-
localStatus: "pending",
|
|
194
|
-
assignee: issue.assigneeEmail,
|
|
195
|
-
priority: issue.priority,
|
|
196
|
-
labels: issue.labels,
|
|
197
|
-
description: issue.description,
|
|
198
|
-
parentIssueId: issue.parentIssueId,
|
|
199
|
-
url: issue.url,
|
|
200
|
-
};
|
|
201
|
-
tasks.push(task);
|
|
208
|
+
// Resolve substring filters to exact names so the query stays narrow while
|
|
209
|
+
// keeping the same matching semantics as the local path. Without --status
|
|
210
|
+
// the scope is what `ttt sync` stores, so an unfiltered --remote search
|
|
211
|
+
// answers the same question as the unfiltered local one.
|
|
212
|
+
let statusNames = getSyncStatuses(getStatusTransitions(config));
|
|
213
|
+
if (filters.status) {
|
|
214
|
+
const statuses = await adapter.getStatuses(teamId);
|
|
215
|
+
const expanded = expandNames(filters.status, statuses.map((s) => s.name));
|
|
216
|
+
if (!expanded)
|
|
217
|
+
return [];
|
|
218
|
+
statusNames = expanded;
|
|
202
219
|
}
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
async function searchIssuesFromLocal(data, filters) {
|
|
206
|
-
let tasks = data.tasks;
|
|
220
|
+
let labelNames;
|
|
207
221
|
if (filters.label) {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
tasks = tasks.filter((t) => t.status.toLowerCase().includes(statusLower));
|
|
222
|
+
const labels = await adapter.getLabels(teamId);
|
|
223
|
+
const expanded = expandNames(filters.label, labels.map((l) => l.name));
|
|
224
|
+
if (!expanded)
|
|
225
|
+
return [];
|
|
226
|
+
labelNames = expanded;
|
|
214
227
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
const issues = await adapter.getIssues({
|
|
229
|
+
teamId,
|
|
230
|
+
cycleId: config.current_cycle?.id,
|
|
231
|
+
statusNames,
|
|
232
|
+
labelNames,
|
|
233
|
+
assigneeEmails: scope.kind === "user" ? scope.emails : undefined,
|
|
234
|
+
});
|
|
235
|
+
const tasks = issues.map((issue) => ({
|
|
236
|
+
id: issue.id,
|
|
237
|
+
linearId: issue.sourceId,
|
|
238
|
+
sourceId: issue.sourceId,
|
|
239
|
+
sourceType,
|
|
240
|
+
title: issue.title,
|
|
241
|
+
status: issue.status,
|
|
242
|
+
localStatus: "pending",
|
|
243
|
+
assignee: issue.assigneeEmail,
|
|
244
|
+
priority: issue.priority,
|
|
245
|
+
labels: issue.labels,
|
|
246
|
+
description: issue.description,
|
|
247
|
+
parentIssueId: issue.parentIssueId,
|
|
248
|
+
url: issue.url,
|
|
249
|
+
}));
|
|
250
|
+
// Carry over local status so a remote search reads like the local one.
|
|
251
|
+
const data = await loadCycleData();
|
|
252
|
+
if (data) {
|
|
253
|
+
const localById = new Map(data.tasks.map((task) => [task.id, task]));
|
|
254
|
+
for (const task of tasks) {
|
|
255
|
+
const local = localById.get(task.id);
|
|
256
|
+
if (local) {
|
|
257
|
+
task.localStatus = local.localStatus;
|
|
258
|
+
task.estimate = local.estimate;
|
|
233
259
|
}
|
|
234
260
|
}
|
|
235
|
-
else {
|
|
236
|
-
tasks = tasks.filter((t) => t.assignee?.toLowerCase().includes(assigneeLower));
|
|
237
|
-
}
|
|
238
261
|
}
|
|
239
|
-
|
|
240
|
-
tasks = tasks.filter((t) => t.priority === filters.priority);
|
|
241
|
-
}
|
|
242
|
-
return tasks;
|
|
262
|
+
return applyFilters(tasks, filters, scope);
|
|
243
263
|
}
|
|
244
264
|
async function show() {
|
|
245
265
|
const args = process.argv.slice(2);
|
|
@@ -259,6 +279,11 @@ Options:
|
|
|
259
279
|
--user <email> Filter by assignee (use "me" for yourself, "unassigned" for no assignee)
|
|
260
280
|
--priority <n> Filter by priority (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low)
|
|
261
281
|
|
|
282
|
+
Searches are scoped the same way ttt sync is: current cycle, current_user,
|
|
283
|
+
and Todo/In Progress. --status and --user replace the corresponding scope.
|
|
284
|
+
The local cache only holds current_user's issues, so searching another
|
|
285
|
+
assignee needs --remote.
|
|
286
|
+
|
|
262
287
|
Examples:
|
|
263
288
|
ttt show # Show all issues in local cycle data
|
|
264
289
|
ttt show MP-624 # Show specific issue from local data
|
|
@@ -267,13 +292,17 @@ Examples:
|
|
|
267
292
|
ttt show --label frontend # Filter local issues by label
|
|
268
293
|
ttt show --status "In Progress" --user me # My in-progress issues
|
|
269
294
|
ttt show --priority 1 # Show all urgent issues
|
|
295
|
+
ttt show --user tony --remote # Someone else's issues (needs --remote)
|
|
270
296
|
ttt show --export # Export all issues as markdown`);
|
|
271
297
|
process.exit(0);
|
|
272
298
|
}
|
|
273
299
|
const useRemote = args.includes("--remote");
|
|
274
300
|
const exportMarkdown = args.includes("--export");
|
|
275
|
-
// Parse filters
|
|
301
|
+
// Parse filters. Option values are recorded so the issue-ID scan below can
|
|
302
|
+
// skip them - a value like "unassigned" otherwise matches the Trello
|
|
303
|
+
// shortLink pattern and hijacks the whole command.
|
|
276
304
|
const filters = {};
|
|
305
|
+
const optionValues = new Set();
|
|
277
306
|
for (let i = 0; i < args.length; i++) {
|
|
278
307
|
const arg = args[i];
|
|
279
308
|
if (arg === "--label" && args[i + 1]) {
|
|
@@ -288,36 +317,27 @@ Examples:
|
|
|
288
317
|
else if (arg === "--priority" && args[i + 1]) {
|
|
289
318
|
filters.priority = parseInt(args[++i], 10);
|
|
290
319
|
}
|
|
291
|
-
}
|
|
292
|
-
// Check if this is a search (has filters) or single issue lookup
|
|
293
|
-
const hasFilters = Object.keys(filters).length > 0;
|
|
294
|
-
// Find issue ID: Linear-style (MP-123) or Trello shortLink (8+ alphanumeric)
|
|
295
|
-
const issueId = args.find((arg) => !arg.startsWith("-") &&
|
|
296
|
-
(arg.match(/^[A-Z]+-\d+$/i) || arg.match(/^[A-Za-z0-9]{8,}$/)));
|
|
297
|
-
// If no issue ID and no filters, show all local issues
|
|
298
|
-
if (!issueId && !hasFilters) {
|
|
299
|
-
const data = await loadCycleData();
|
|
300
|
-
if (!data) {
|
|
301
|
-
console.error("No cycle data found. Run ttt sync first.");
|
|
302
|
-
process.exit(1);
|
|
303
|
-
}
|
|
304
|
-
if (exportMarkdown) {
|
|
305
|
-
console.log(tasksToMarkdownList(data.tasks));
|
|
306
|
-
}
|
|
307
320
|
else {
|
|
308
|
-
|
|
321
|
+
continue;
|
|
309
322
|
}
|
|
310
|
-
|
|
323
|
+
optionValues.add(i);
|
|
311
324
|
}
|
|
312
|
-
//
|
|
313
|
-
|
|
325
|
+
// Find issue ID: Linear-style (MP-123) or Trello shortLink (8+ alphanumeric)
|
|
326
|
+
const issueId = args.find((arg, i) => !optionValues.has(i) &&
|
|
327
|
+
!arg.startsWith("-") &&
|
|
328
|
+
(arg.match(/^[A-Z]+-\d+$/i) || arg.match(/^[A-Za-z0-9]{8,}$/)));
|
|
329
|
+
// List mode: no specific issue ID. Filters are optional; --remote runs the
|
|
330
|
+
// same query live instead of reading the cache.
|
|
331
|
+
if (!issueId) {
|
|
332
|
+
const userEmails = await getUserEmails();
|
|
333
|
+
const scope = resolveAssigneeScope(filters.assignee, userEmails);
|
|
314
334
|
let tasks;
|
|
315
335
|
if (useRemote) {
|
|
316
336
|
const config = await loadConfig();
|
|
317
337
|
const sourceType = getSourceType(config);
|
|
318
338
|
const sourceName = sourceType === "trello" ? "Trello" : "Linear";
|
|
319
339
|
console.error(`Searching issues from ${sourceName}...`);
|
|
320
|
-
tasks = await searchIssuesFromRemote(filters);
|
|
340
|
+
tasks = await searchIssuesFromRemote(filters, scope);
|
|
321
341
|
}
|
|
322
342
|
else {
|
|
323
343
|
const data = await loadCycleData();
|
|
@@ -325,7 +345,14 @@ Examples:
|
|
|
325
345
|
console.error("No cycle data found. Run ttt sync first.");
|
|
326
346
|
process.exit(1);
|
|
327
347
|
}
|
|
328
|
-
tasks =
|
|
348
|
+
tasks = applyFilters(data.tasks, filters, scope);
|
|
349
|
+
// The cache only holds what `ttt sync` scoped to current_user, so an
|
|
350
|
+
// empty result here is a scope limit, not an answer.
|
|
351
|
+
if (tasks.length === 0 && scopeEscapesLocalCache(scope, userEmails)) {
|
|
352
|
+
console.error(`Local cache only contains issues assigned to ${userEmails.join(", ")}.`);
|
|
353
|
+
console.error(`Add --remote to search others: ttt show --user ${filters.assignee} --remote`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
329
356
|
}
|
|
330
357
|
if (exportMarkdown) {
|
|
331
358
|
console.log(tasksToMarkdownList(tasks));
|
package/dist/scripts/sync.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createAdapter } from "./lib/adapters/index.js";
|
|
2
2
|
import { clearAllOutput, clearIssueImages, downloadLinearImage, downloadTrelloFile, ensureOutputDir, extractImageUrls, isLinearImageUrl, } from "./lib/files.js";
|
|
3
|
+
import { fetchIssuesPaged, MAX_FETCHED_ISSUES } from "./lib/linear.js";
|
|
3
4
|
import { getReviewStatuses, getSyncStatuses, resolveLocalStatus, } from "./lib/status-helpers.js";
|
|
4
|
-
import { getLinearClient, getPaths, getPrioritySortIndex, getSourceType, getTeamId, loadConfig, loadCycleData, loadLocalConfig, preserveLocalTaskFields, saveConfig, saveCycleData, withRetry, } from "./utils.js";
|
|
5
|
+
import { getLinearClient, getPaths, getPrioritySortIndex, getSourceType, getTeamId, getUserEmails, loadConfig, loadCycleData, loadLocalConfig, preserveLocalTaskFields, saveConfig, saveCycleData, withRetry, } from "./utils.js";
|
|
5
6
|
async function downloadEmbeddedImages(texts, issueId, attachments, outputDir, downloadFile, titlePrefix, sourceType, filterUrl) {
|
|
6
7
|
let imageIndex = 0;
|
|
7
8
|
for (const text of texts) {
|
|
@@ -45,6 +46,7 @@ Options:
|
|
|
45
46
|
What it does:
|
|
46
47
|
- Fetches active cycle from Linear
|
|
47
48
|
- Downloads issues with Todo/In Progress status (or all with --all)
|
|
49
|
+
- Filters by current_user assignee (all users if none configured)
|
|
48
50
|
- Filters by label if configured
|
|
49
51
|
- Preserves local status for existing tasks
|
|
50
52
|
- Updates config with new cycle info
|
|
@@ -201,9 +203,11 @@ Examples:
|
|
|
201
203
|
// Phase 4: Fetch current issues with full content
|
|
202
204
|
const filterLabels = localConfig.labels;
|
|
203
205
|
const syncStatuses = getSyncStatuses(statusTransitions);
|
|
206
|
+
const userEmails = await getUserEmails();
|
|
204
207
|
const labelDesc = filterLabels && filterLabels.length > 0
|
|
205
208
|
? ` with labels: ${filterLabels.join(", ")}`
|
|
206
209
|
: "";
|
|
210
|
+
const assigneeDesc = userEmails.length > 0 ? ` assigned to ${userEmails.join(", ")}` : "";
|
|
207
211
|
let issues;
|
|
208
212
|
if (singleIssueId) {
|
|
209
213
|
// Sync single issue by ID
|
|
@@ -221,9 +225,10 @@ Examples:
|
|
|
221
225
|
const statusDesc = syncAll
|
|
222
226
|
? "all statuses"
|
|
223
227
|
: `${syncStatuses.join("/")} status`;
|
|
224
|
-
console.log(`Fetching issues (${statusDesc})${labelDesc}...`);
|
|
225
|
-
// Build filter - label
|
|
226
|
-
// active cycle (Linear lets teams disable cycles
|
|
228
|
+
console.log(`Fetching issues (${statusDesc})${assigneeDesc}${labelDesc}...`);
|
|
229
|
+
// Build filter - label and assignee are optional; cycle is skipped if
|
|
230
|
+
// the team has no active cycle (Linear lets teams disable cycles
|
|
231
|
+
// entirely). No configured user means team-wide sync.
|
|
227
232
|
const issueFilter = {
|
|
228
233
|
team: { id: { eq: teamId } },
|
|
229
234
|
};
|
|
@@ -236,10 +241,15 @@ Examples:
|
|
|
236
241
|
if (filterLabels && filterLabels.length > 0) {
|
|
237
242
|
issueFilter.labels = { name: { in: filterLabels } };
|
|
238
243
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
if (userEmails.length > 0) {
|
|
245
|
+
issueFilter.assignee = { email: { in: userEmails } };
|
|
246
|
+
}
|
|
247
|
+
// Paginate so a large cycle isn't silently truncated to one page.
|
|
248
|
+
const page = await fetchIssuesPaged(client, issueFilter, "fetch issues");
|
|
249
|
+
if (page.truncated) {
|
|
250
|
+
console.warn(`\nWarning: stopped at ${MAX_FETCHED_ISSUES} issues; more matched the filter.`);
|
|
251
|
+
}
|
|
252
|
+
issues = { nodes: page.nodes };
|
|
243
253
|
}
|
|
244
254
|
if (issues.nodes.length === 0) {
|
|
245
255
|
console.log(`No issues found in current cycle${labelDesc}.`);
|
|
@@ -492,10 +502,12 @@ async function syncTrello(config, localConfig, options) {
|
|
|
492
502
|
? "all statuses"
|
|
493
503
|
: `${syncStatuses.join("/")} status`;
|
|
494
504
|
const filterLabels = localConfig.labels;
|
|
505
|
+
const userEmails = await getUserEmails();
|
|
495
506
|
const labelDesc = filterLabels && filterLabels.length > 0
|
|
496
507
|
? ` with labels: ${filterLabels.join(", ")}`
|
|
497
508
|
: "";
|
|
498
|
-
|
|
509
|
+
const assigneeDesc = userEmails.length > 0 ? ` assigned to ${userEmails.join(", ")}` : "";
|
|
510
|
+
console.log(`Fetching cards (${statusDesc})${assigneeDesc}${labelDesc}...`);
|
|
499
511
|
let issues;
|
|
500
512
|
if (singleIssueId) {
|
|
501
513
|
// Sync single issue
|
|
@@ -508,8 +520,9 @@ async function syncTrello(config, localConfig, options) {
|
|
|
508
520
|
teamId,
|
|
509
521
|
statusNames: syncAll ? undefined : syncStatuses,
|
|
510
522
|
labelNames: filterLabels,
|
|
523
|
+
assigneeEmails: userEmails.length > 0 ? userEmails : undefined,
|
|
511
524
|
excludeLabels: localConfig.exclude_labels,
|
|
512
|
-
limit:
|
|
525
|
+
limit: MAX_FETCHED_ISSUES,
|
|
513
526
|
});
|
|
514
527
|
}
|
|
515
528
|
if (issues.length === 0) {
|
package/dist/scripts/work-on.js
CHANGED
|
@@ -48,7 +48,7 @@ Examples:
|
|
|
48
48
|
if (currentUserEmails.length === 0)
|
|
49
49
|
return true; // No filter = all users
|
|
50
50
|
if (!t.assignee)
|
|
51
|
-
return
|
|
51
|
+
return false;
|
|
52
52
|
return currentUserEmails.includes(t.assignee.toLowerCase());
|
|
53
53
|
})
|
|
54
54
|
.sort((a, b) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "team-toon-tack",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.3",
|
|
4
4
|
"description": "Linear & Trello task sync & management CLI with TOON format",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
16
|
".claude-plugin",
|
|
17
|
+
".codex-plugin",
|
|
17
18
|
"commands",
|
|
18
19
|
"skills"
|
|
19
20
|
],
|
|
@@ -3,12 +3,13 @@ name: managing-linear-tasks
|
|
|
3
3
|
description: Use when the user mentions Linear or Trello issues, references issue IDs like MP-123, or asks to sync, show, start, complete, create, assign, edit, cancel, comment on, or check status of tasks.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
## 執行方式
|
|
7
|
+
|
|
8
|
+
在 Codex 與 Claude Code 中,使用可用的 shell 工具執行 `ttt` CLI。
|
|
9
|
+
任務資料以 CLI 的查詢結果為準;透過 CLI 更新 `.ttt/` 內的設定與狀態。
|
|
10
|
+
使用者要求完成任務時,執行 `ttt done -m "摘要"`。
|
|
11
|
+
Claude Code 也提供 `/ttt:*` slash commands,對應下方相同的 CLI 操作。
|
|
12
|
+
|
|
12
13
|
|
|
13
14
|
# Task Manager (Linear/Trello)
|
|
14
15
|
|
|
@@ -16,23 +17,23 @@ Manage developer task workflows using the `ttt` CLI.
|
|
|
16
17
|
|
|
17
18
|
## Command Router
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
依使用者意圖選擇下列 `ttt` CLI 指令:
|
|
20
21
|
|
|
21
22
|
| User Intent | Command | Example |
|
|
22
23
|
|-------------|---------|---------|
|
|
23
|
-
| Sync/fetch issues |
|
|
24
|
-
| Show/search issues |
|
|
25
|
-
| Start working on a task |
|
|
26
|
-
| Create a new issue |
|
|
27
|
-
| Reassign an issue |
|
|
28
|
-
| Edit issue fields |
|
|
29
|
-
| Cancel an issue |
|
|
30
|
-
| Record an estimate |
|
|
31
|
-
| Check/change status |
|
|
32
|
-
| Add a comment |
|
|
33
|
-
| Complete a task |
|
|
34
|
-
|
|
35
|
-
|
|
24
|
+
| Sync/fetch issues | `ttt sync` | "sync my issues", "pull from Linear" |
|
|
25
|
+
| Show/search issues | `ttt show` | "show MP-624", "list my tasks", "what issues do I have" |
|
|
26
|
+
| Start working on a task | `ttt work-on` | "work on next", "start MP-624" |
|
|
27
|
+
| Create a new issue | `ttt create` | "create issue", "open a ticket", "new task" |
|
|
28
|
+
| Reassign an issue | `ttt assign` | "assign MP-624 to john", "reassign to jane" |
|
|
29
|
+
| Edit issue fields | `ttt edit` | "rename MP-624", "change priority", "update labels" |
|
|
30
|
+
| Cancel an issue | `ttt cancel` | "cancel MP-624", "abandon this task" |
|
|
31
|
+
| Record an estimate | `ttt estimate` | "estimate MP-624 as 6h", "save 2.5h estimate" |
|
|
32
|
+
| Check/change status | `ttt status` | "what's my current task", "set MP-624 to blocked" |
|
|
33
|
+
| Add a comment | `ttt comment` | "comment on MP-624", "add note to task" |
|
|
34
|
+
| Complete a task | `ttt done` | "done", "mark complete", "finish task" |
|
|
35
|
+
|
|
36
|
+
**意圖符合時,透過 shell 工具執行對應的 `ttt` CLI 指令。**
|
|
36
37
|
|
|
37
38
|
## Quick Reference
|
|
38
39
|
|
|
@@ -96,7 +97,7 @@ ttt sync → ttt work-on next → ttt estimate <id> <hours> → [implement] →
|
|
|
96
97
|
| "I'll edit `cycle.toon` directly, faster" | `cycle.toon` is auto-generated. Manual edits get overwritten on next `ttt sync`. Always go through the CLI. |
|
|
97
98
|
| "I remember this issue ID, no need to sync" | Remote status, assignee, priority may have changed. Run `ttt sync` or `ttt show <id> --remote` first. |
|
|
98
99
|
| "I'll create the issue in the Linear/Trello UI instead" | Bypassing `ttt create` leaves the new issue outside local cycle data. Use the CLI so it gets tracked. |
|
|
99
|
-
| "Task is obvious, skip
|
|
100
|
+
| "Task is obvious, skip `ttt done`" | `ttt done` syncs local + remote status, posts a completion comment, reads git commit info. Skipping leaves state inconsistent. |
|
|
100
101
|
| "I know what the task needs without reading it" | Fabricated assumptions produce wrong work. Run `ttt show <id>` before starting. |
|
|
101
102
|
| "Fetch issue details via Linear MCP / web UI" | The CLI is authoritative and token-efficient. Use `ttt show` / `ttt sync`, not alternate data paths. |
|
|
102
103
|
|
|
@@ -109,17 +110,17 @@ digraph ttt_router {
|
|
|
109
110
|
user [label="User mentions\nLinear/Trello", shape=doublecircle];
|
|
110
111
|
classify [label="Classify intent", shape=diamond];
|
|
111
112
|
|
|
112
|
-
sync [label="
|
|
113
|
-
show [label="
|
|
114
|
-
work_on [label="
|
|
115
|
-
create [label="
|
|
116
|
-
assign [label="
|
|
117
|
-
edit [label="
|
|
118
|
-
cancel [label="
|
|
119
|
-
estimate [label="
|
|
120
|
-
status [label="
|
|
121
|
-
comment [label="
|
|
122
|
-
done [label="
|
|
113
|
+
sync [label="ttt sync", shape=box];
|
|
114
|
+
show [label="ttt show", shape=box];
|
|
115
|
+
work_on [label="ttt work-on", shape=box];
|
|
116
|
+
create [label="ttt create", shape=box];
|
|
117
|
+
assign [label="ttt assign", shape=box];
|
|
118
|
+
edit [label="ttt edit", shape=box];
|
|
119
|
+
cancel [label="ttt cancel", shape=box];
|
|
120
|
+
estimate [label="ttt estimate", shape=box];
|
|
121
|
+
status [label="ttt status", shape=box];
|
|
122
|
+
comment [label="ttt comment", shape=box];
|
|
123
|
+
done [label="ttt done", shape=box];
|
|
123
124
|
|
|
124
125
|
user -> classify;
|
|
125
126
|
classify -> sync [label="fetch / pull"];
|