team-toon-tack 3.7.7 → 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/README.zh-TW.md +4 -1
- package/dist/scripts/lib/adapters/linear-adapter.js +15 -30
- package/dist/scripts/lib/adapters/trello-adapter.js +8 -0
- 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 +1 -1
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
|
|
@@ -111,6 +111,9 @@ ttt init --force # Overwrite existing config
|
|
|
111
111
|
|
|
112
112
|
Sync current cycle issues from Linear/Trello.
|
|
113
113
|
|
|
114
|
+
Only issues assigned to `current_user` are synced. Leave `current_user` empty in
|
|
115
|
+
`.ttt/local.toon` for a team-wide sync.
|
|
116
|
+
|
|
114
117
|
```bash
|
|
115
118
|
ttt sync # Sync Todo/In Progress issues (fast)
|
|
116
119
|
ttt sync --all # Sync all issues regardless of status
|
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):簡單、嚴格審查、上下游嚴格、上下游非嚴格
|
|
@@ -111,6 +111,9 @@ ttt init --force # 覆蓋現有配置
|
|
|
111
111
|
|
|
112
112
|
從 Linear/Trello 同步當前 cycle 的 issue。
|
|
113
113
|
|
|
114
|
+
只同步指派給 `current_user` 的 issue。若要同步整個團隊,把 `.ttt/local.toon` 的
|
|
115
|
+
`current_user` 留空。
|
|
116
|
+
|
|
114
117
|
```bash
|
|
115
118
|
ttt sync # 同步 Todo/In Progress 狀態的 issue(較快)
|
|
116
119
|
ttt sync --all # 同步所有狀態的 issue
|
|
@@ -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;
|
|
@@ -103,6 +103,14 @@ export class TrelloAdapter {
|
|
|
103
103
|
// Get first assignee
|
|
104
104
|
const firstMemberId = card.idMembers[0];
|
|
105
105
|
const assignee = firstMemberId ? memberMap.get(firstMemberId) : undefined;
|
|
106
|
+
// Filter by assignee if specified (OR logic)
|
|
107
|
+
if (options.assigneeEmails && options.assigneeEmails.length > 0) {
|
|
108
|
+
const email = assignee?.email?.toLowerCase();
|
|
109
|
+
if (!email ||
|
|
110
|
+
!options.assigneeEmails.some((e) => e.toLowerCase() === email)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
106
114
|
// Detect priority from labels
|
|
107
115
|
const priority = detectPriorityFromLabels(labelNames);
|
|
108
116
|
issues.push({
|
|
@@ -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) => {
|