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 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 unassigned work with `/work-on next`
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.assigneeEmail) {
92
- filter.assignee = { email: { eq: options.assigneeEmail } };
92
+ if (options.assigneeEmails && options.assigneeEmails.length > 0) {
93
+ filter.assignee = { email: { in: options.assigneeEmails } };
93
94
  }
94
- const issuesData = await this.client.issues({
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 issuesData.nodes) {
101
- const state = await issue.state;
102
- const assignee = await issue.assignee;
103
- const labels = await issue.labels();
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: attachments.length > 0 ? attachments : undefined,
142
- comments: comments.length > 0 ? comments : undefined,
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({
@@ -95,7 +95,7 @@ export interface GetIssuesOptions {
95
95
  cycleId?: string;
96
96
  statusNames?: string[];
97
97
  labelNames?: string[];
98
- assigneeEmail?: string;
98
+ assigneeEmails?: string[];
99
99
  excludeLabels?: string[];
100
100
  limit?: number;
101
101
  }
@@ -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);
@@ -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 { getSourceType, loadConfig, loadCycleData, loadLocalConfig, } from "./utils.js";
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
- async function searchIssuesFromRemote(filters) {
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
- // Use adapter to get issues with filters
146
- // Note: adapter API is simpler, filters applied in-memory for consistency
147
- const issues = await adapter.getIssues({
148
- teamId,
149
- statusNames: filters.status ? [filters.status] : undefined,
150
- labelNames: filters.label ? [filters.label] : undefined,
151
- limit: 50,
152
- });
153
- const tasks = [];
154
- for (const issue of issues) {
155
- // Apply additional filters
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
- return tasks;
204
- }
205
- async function searchIssuesFromLocal(data, filters) {
206
- let tasks = data.tasks;
220
+ let labelNames;
207
221
  if (filters.label) {
208
- const labelLower = filters.label.toLowerCase();
209
- tasks = tasks.filter((t) => t.labels.some((l) => l.toLowerCase().includes(labelLower)));
210
- }
211
- if (filters.status) {
212
- const statusLower = filters.status.toLowerCase();
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
- if (filters.assignee) {
216
- const assigneeLower = filters.assignee.toLowerCase();
217
- if (assigneeLower === "unassigned") {
218
- tasks = tasks.filter((t) => !t.assignee);
219
- }
220
- else if (assigneeLower === "me") {
221
- const localConfig = await loadLocalConfig();
222
- const config = await loadConfig();
223
- const userKeys = Array.isArray(localConfig.current_user)
224
- ? localConfig.current_user
225
- : localConfig.current_user
226
- ? [localConfig.current_user]
227
- : [];
228
- const userEmails = userKeys
229
- .map((key) => config.users[key]?.email?.toLowerCase())
230
- .filter((e) => !!e);
231
- if (userEmails.length > 0) {
232
- tasks = tasks.filter((t) => t.assignee && userEmails.includes(t.assignee.toLowerCase()));
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
- if (filters.priority !== undefined) {
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
- displayTaskList(data.tasks);
321
+ continue;
309
322
  }
310
- return;
323
+ optionValues.add(i);
311
324
  }
312
- // Search mode: has filters but no specific issue ID
313
- if (hasFilters && !issueId) {
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 = await searchIssuesFromLocal(data, filters);
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));
@@ -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 is optional; cycle is skipped if team has no
226
- // active cycle (Linear lets teams disable cycles entirely).
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
- issues = await withRetry(() => client.issues({
240
- filter: issueFilter,
241
- first: 50,
242
- }), { label: "fetch issues" });
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
- console.log(`Fetching cards (${statusDesc})${labelDesc}...`);
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: 100,
525
+ limit: MAX_FETCHED_ISSUES,
513
526
  });
514
527
  }
515
528
  if (issues.length === 0) {
@@ -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 true; // Include unassigned tasks
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.7.7",
3
+ "version": "3.9.0",
4
4
  "description": "Linear & Trello task sync & management CLI with TOON format",
5
5
  "type": "module",
6
6
  "bin": {