team-toon-tack 3.11.0 → 3.12.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.
@@ -13,7 +13,7 @@
13
13
  "name": "team-toon-tack",
14
14
  "source": "./",
15
15
  "description": "Linear/Trello task sync & management CLI with commands and skills",
16
- "version": "3.11.0"
16
+ "version": "3.12.0"
17
17
  }
18
18
  ]
19
19
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "team-toon-tack",
3
3
  "description": "Linear/Trello task sync & management CLI for Claude Code - saves tokens vs MCP",
4
- "version": "3.11.0",
4
+ "version": "3.12.0",
5
5
  "author": {
6
6
  "name": "wayne930242",
7
7
  "email": "wayne930242@gmail.com"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "team-toon-tack",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "透過 ttt CLI 同步與管理 Linear、Trello 任務。",
5
5
  "author": {
6
6
  "name": "wayne930242"
package/README.md CHANGED
@@ -159,7 +159,14 @@ ttt claim --dry-run # Preview without changes
159
159
  ```
160
160
 
161
161
  Completed, in-review, and blocked tickets are reported and skipped without
162
- stopping the rest of the batch. An unknown ID aborts before anything is claimed.
162
+ stopping the rest of the batch. An ID that local data does not hold (for example
163
+ a Backlog ticket outside the cycle) is fetched from the source, stored locally,
164
+ and claimed like any other; with `--dry-run` it is shown but not stored. An ID
165
+ found neither locally nor at the source aborts before anything is claimed.
166
+ A ticket that is already closed (done or canceled) is reported and skipped. Note
167
+ that a full `ttt sync` rewrites local data from the current cycle, so a stored
168
+ ticket outside it drops out on the next sync (`ttt done <id> --from-remote`
169
+ still completes it).
163
170
 
164
171
  ### `ttt estimate`
165
172
 
@@ -213,8 +220,22 @@ ttt show --label frontend # Filter by label
213
220
  ttt show --status "In Progress" --user me # My in-progress issues
214
221
  ttt show --priority 1 # Show urgent issues
215
222
  ttt show --export # Export as markdown
223
+ ttt show --remote --status Backlog --creator me # Backlog tickets I created
224
+ ttt show --remote --status Backlog --user me # Backlog tickets assigned to me
225
+ ttt show --remote --any-cycle --creator me # My tickets in any cycle
216
226
  ```
217
227
 
228
+ Two options widen a `--remote` search beyond what `ttt sync` stores (both need
229
+ `--remote`; otherwise the command fails instead of ignoring them):
230
+
231
+ - `--creator <me|email|name>` filters by who created the issue (Linear only;
232
+ Trello cards expose no creator, so it fails there). Without `--user`, the
233
+ assignee scope is lifted, so tickets you created for others or left
234
+ unassigned show up.
235
+ - `--any-cycle` searches outside the current cycle. `--status Backlog` implies
236
+ it, because backlog tickets usually belong to no cycle. Trello has no cycles,
237
+ so there it changes nothing.
238
+
218
239
  ### `ttt config`
219
240
 
220
241
  Configure settings.
package/README.zh-TW.md CHANGED
@@ -159,7 +159,10 @@ ttt claim --dry-run # 預覽,不改狀態
159
159
  ```
160
160
 
161
161
  已完成、審核中、blocked 的票會逐張回報並跳過,不中斷整批。
162
- 批次中出現不存在的 ID 時,整批在動任何票之前中止。
162
+ 本地資料沒有的 ID(例如不在 cycle 內的 Backlog 票)會從來源抓回、存進本地,再照一般流程領票;
163
+ 加 `--dry-run` 時只顯示、不存入。本地與來源都找不到的 ID,整批在動任何票之前中止。
164
+ 已關閉(完成或取消)的票會回報並跳過。
165
+ 注意:完整的 `ttt sync` 會依目前 cycle 重寫本地資料,不在 cycle 內的票下次同步後會消失(仍可用 `ttt done <id> --from-remote` 完成)。
163
166
 
164
167
  ### `ttt estimate`
165
168
 
@@ -213,8 +216,18 @@ ttt show --label frontend # 依標籤過濾
213
216
  ttt show --status "In Progress" --user me # 我進行中的 issue
214
217
  ttt show --priority 1 # 顯示緊急 issue
215
218
  ttt show --export # 輸出為 markdown 格式
219
+ ttt show --remote --status Backlog --creator me # 我建立的 Backlog 票
220
+ ttt show --remote --status Backlog --user me # 指派給我的 Backlog 票
221
+ ttt show --remote --any-cycle --creator me # 我建立、不限 cycle 的票
216
222
  ```
217
223
 
224
+ 兩個選項可把 `--remote` 搜尋範圍放寬到 `ttt sync` 之外(都必須搭配 `--remote`,否則直接報錯,不會默默忽略):
225
+
226
+ - `--creator <me|email|name>`:依建立者過濾(僅 Linear;Trello 卡片沒有建立者,會報錯)。
227
+ 未指定 `--user` 時會解除指派對象的限制,因此你建給別人或未指派的票也找得到。
228
+ - `--any-cycle`:不限目前 cycle。`--status Backlog` 會自動帶上它,因為 Backlog 票通常不屬於任何 cycle。
229
+ Trello 沒有 cycle,所以在 Trello 上沒有作用。
230
+
218
231
  ### `ttt config`
219
232
 
220
233
  配置設定。
@@ -20,6 +20,12 @@ arguments:
20
20
  - name: remote
21
21
  description: Fetch from Linear API instead of local data
22
22
  required: false
23
+ - name: creator
24
+ description: "Filter by creator ('me', email, or name). Needs remote; Linear only"
25
+ required: false
26
+ - name: any-cycle
27
+ description: Search outside the current cycle. Needs remote
28
+ required: false
23
29
  - name: export
24
30
  description: Output as markdown format
25
31
  required: false
@@ -43,6 +49,8 @@ ttt show {{ issue-id }} \
43
49
  {{ "--status \"" + status + "\"" if status }} \
44
50
  {{ "--user " + user if user }} \
45
51
  {{ "--priority " + priority if priority }} \
52
+ {{ "--creator " + creator if creator }} \
53
+ {{ "--any-cycle" if any-cycle }} \
46
54
  {{ "--remote" if remote }} \
47
55
  {{ "--export" if export }}
48
56
  ```
@@ -58,6 +66,9 @@ ttt show --label frontend # Filter by label
58
66
  ttt show --status "In Progress" --user me # My in-progress issues
59
67
  ttt show --priority 1 # All urgent issues
60
68
  ttt show --export # Export all as markdown
69
+ ttt show --remote --status Backlog --creator me # Backlog tickets I created
70
+ ttt show --remote --status Backlog --user me # Backlog tickets assigned to me
71
+ ttt show --remote --any-cycle --creator me # My tickets in any cycle
61
72
  ```
62
73
 
63
74
  ## Full CLI Reference
@@ -75,8 +86,14 @@ Options:
75
86
  --status <status> Filter by status
76
87
  --user <email> Filter by assignee ("me", "unassigned", or email)
77
88
  --priority <n> Filter by priority (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low)
89
+ --creator <who> Filter by creator ("me", email, or name). Needs --remote,
90
+ Linear only. Without --user, the assignee scope is lifted
91
+ --any-cycle Search outside the current cycle. Needs --remote
78
92
  ```
79
93
 
94
+ `--status Backlog` implies `--any-cycle`. `--creator` and `--any-cycle` fail
95
+ without `--remote` instead of being ignored, and `--creator` fails on Trello.
96
+
80
97
  ## After Execution
81
98
 
82
99
  Present the output to the user. If `--export` was used, the output is markdown-formatted and can be used as context.
@@ -52,14 +52,18 @@ Arguments:
52
52
  'next' claims the highest priority pending task
53
53
  'next <n>' claims the top <n> pending tasks
54
54
  If omitted, shows interactive multi-select
55
+ An ID missing from local data (e.g. a Backlog ticket outside
56
+ the cycle) is fetched from the source, stored locally, and
57
+ claimed like any other
55
58
 
56
59
  Options:
57
60
  --dry-run Pick tickets without changing status (preview only)
58
61
  ```
59
62
 
60
63
  Tickets that are already completed, in review, or blocked are reported and
61
- skipped without stopping the rest of the batch. An unknown ID aborts before
62
- anything is claimed.
64
+ skipped without stopping the rest of the batch. An ID found neither locally nor
65
+ at the source aborts before anything is claimed. Find Backlog tickets first with
66
+ `ttt show --remote --status Backlog --creator me` (or `--user me`), then claim by ID.
63
67
 
64
68
  ## After Claiming — Delegate the Implementation
65
69
 
@@ -2,6 +2,8 @@ import { checkbox } from "@inquirer/prompts";
2
2
  import { createAdapter } from "./lib/adapters/index.js";
3
3
  import { displayTaskFull, PRIORITY_LABELS } from "./lib/display.js";
4
4
  import { getStatusTransitions } from "./lib/linear.js";
5
+ import { sourceIssueToTask } from "./lib/remote-task.js";
6
+ import { mapRemoteToLocalStatus } from "./lib/status-helpers.js";
5
7
  import { findTaskByIssueId, getPrioritySortIndex, getSourceType, loadConfig, loadCycleData, loadLocalConfig, saveCycleData, } from "./utils.js";
6
8
  const NEXT_ALIASES = ["next", "下一個", "下一個工作"];
7
9
  const NO_PENDING_MESSAGE = "✅ 沒有待處理的任務,所有工作已完成或進行中";
@@ -15,6 +17,9 @@ Arguments:
15
17
  'next' claims the highest priority pending task
16
18
  'next <n>' claims the top <n> pending tasks
17
19
  If omitted, shows interactive multi-select
20
+ An ID missing from local data (e.g. a Backlog ticket outside
21
+ the cycle) is fetched from the source, stored locally, and
22
+ claimed like any other
18
23
 
19
24
  Options:
20
25
  --dry-run Pick tickets without changing status (preview only)
@@ -88,24 +93,65 @@ function selectNext(countArg, pendingTasks) {
88
93
  console.log(`Auto-selected: ${ids.join(", ")}`);
89
94
  return ids;
90
95
  }
91
- /** Resolve the requested tickets, exiting if any ID is unknown. */
92
- function resolveTasks(data, issueIds) {
96
+ /**
97
+ * Fetch a ticket that local data does not hold. Returns null when the source
98
+ * is unreachable or has no such ticket; the caller aborts the batch.
99
+ */
100
+ async function fetchRemoteTask(issueId, config, localConfig) {
101
+ try {
102
+ const adapter = createAdapter(config);
103
+ const issue = await adapter.searchIssue(issueId);
104
+ if (!issue)
105
+ return null;
106
+ const task = sourceIssueToTask(issue, getSourceType(config));
107
+ task.localStatus = mapRemoteToLocalStatus(task.status, getStatusTransitions(config), localConfig);
108
+ // Closed states are named per team ("Canceled", "Duplicate", ...), so
109
+ // the configured done name alone misses them; the state type does not.
110
+ const teamId = config.teams[localConfig.team]?.id;
111
+ if (task.localStatus === "pending" && teamId) {
112
+ const statuses = await adapter.getStatuses(teamId);
113
+ const type = statuses.find((s) => s.name === task.status)?.type;
114
+ if (type === "completed" || type === "canceled") {
115
+ task.localStatus = "completed";
116
+ }
117
+ }
118
+ return task;
119
+ }
120
+ catch (error) {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ console.error(`Could not look up ${issueId} remotely: ${message}`);
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * Resolve the requested tickets, exiting if any ID is unknown both locally
128
+ * and at the source. `fetched` holds the tickets that came from the source so
129
+ * the caller can store them once it knows they will be claimed.
130
+ */
131
+ async function resolveTasks(data, issueIds, config, localConfig) {
93
132
  const tasks = [];
133
+ const fetched = new Set();
94
134
  const missing = [];
95
135
  for (const issueId of issueIds) {
96
- const task = findTaskByIssueId(data.tasks, issueId);
97
- if (task) {
98
- tasks.push(task);
136
+ const local = findTaskByIssueId(data.tasks, issueId);
137
+ if (local) {
138
+ tasks.push(local);
139
+ continue;
140
+ }
141
+ const remote = await fetchRemoteTask(issueId, config, localConfig);
142
+ if (remote) {
143
+ tasks.push(remote);
144
+ fetched.add(remote);
99
145
  }
100
146
  else {
101
147
  missing.push(issueId);
102
148
  }
103
149
  }
104
150
  if (missing.length > 0) {
105
- console.error(`Issue ${missing.join(", ")} not found in current cycle. No ticket claimed.`);
151
+ console.error(`Issue ${missing.join(", ")} not found in local data or at the source (use the full ID, e.g. MP-123). No ticket claimed.`);
106
152
  process.exit(1);
107
153
  }
108
- return tasks;
154
+ return { tasks, fetched };
109
155
  }
110
156
  function decide(task) {
111
157
  switch (task.localStatus) {
@@ -211,14 +257,27 @@ async function claim() {
211
257
  const pendingTasks = getPendingTasks(data, config, localConfig);
212
258
  const issueIds = await resolveIssueIds(positional, pendingTasks);
213
259
  // Availability Check — resolve the whole batch before mutating anything
214
- const outcomes = resolveTasks(data, issueIds).map(decide);
260
+ const resolved = await resolveTasks(data, issueIds, config, localConfig);
261
+ const outcomes = resolved.tasks.map(decide);
215
262
  const toClaim = pickTasks(outcomes, (a) => a === "claim");
216
263
  const toDisplay = pickTasks(outcomes, (a) => a !== "skip");
217
264
  if (toDisplay.length === 0) {
218
265
  process.exit(0);
219
266
  }
220
- if (toClaim.length > 0 && !dryRun) {
221
- await applyClaims(toClaim, data, config, localConfig);
267
+ // Fetched tickets are stored when they are claimed or already in progress,
268
+ // so a later `ttt done` finds them; a dry run or a skipped ticket leaves
269
+ // local data untouched.
270
+ if (!dryRun) {
271
+ const toStore = outcomes
272
+ .filter((o) => resolved.fetched.has(o.task) && o.action !== "skip")
273
+ .map((o) => o.task);
274
+ data.tasks.push(...toStore);
275
+ if (toClaim.length > 0) {
276
+ await applyClaims(toClaim, data, config, localConfig);
277
+ }
278
+ else if (toStore.length > 0) {
279
+ await saveCycleData(data);
280
+ }
222
281
  }
223
282
  // Display task info
224
283
  const icon = dryRun ? "🔍" : "👷";
@@ -97,6 +97,21 @@ export class LinearAdapter {
97
97
  if (options.assigneeEmails && options.assigneeEmails.length > 0) {
98
98
  filter.assignee = { email: { in: options.assigneeEmails } };
99
99
  }
100
+ if (options.creator?.emails && options.creator.emails.length > 0) {
101
+ filter.creator = {
102
+ email: { in: options.creator.emails },
103
+ };
104
+ }
105
+ else if (options.creator?.match) {
106
+ const needle = options.creator.match;
107
+ filter.creator = {
108
+ or: [
109
+ { name: { containsIgnoreCase: needle } },
110
+ { displayName: { containsIgnoreCase: needle } },
111
+ { email: { containsIgnoreCase: needle } },
112
+ ],
113
+ };
114
+ }
100
115
  const page = await fetchIssuesPaged(this.client, filter, "fetch issues", options.limit ?? MAX_FETCHED_ISSUES);
101
116
  const issues = [];
102
117
  const excludeLabels = new Set(options.excludeLabels ?? []);
@@ -73,6 +73,9 @@ export class TrelloAdapter {
73
73
  return null;
74
74
  }
75
75
  async getIssues(options) {
76
+ if (options.creator) {
77
+ throw new Error("Trello cards do not expose a creator, so a creator filter is not supported.");
78
+ }
76
79
  const cards = await this.client.getBoardCards(options.teamId);
77
80
  // Ensure lists are cached
78
81
  if (!this.listsCache.has(options.teamId)) {
@@ -87,6 +87,14 @@ export interface SourceIssue {
87
87
  attachments?: SourceAttachment[];
88
88
  comments?: SourceComment[];
89
89
  }
90
+ /**
91
+ * Who created an issue. `emails` matches exactly (OR logic); `match` is a
92
+ * case-insensitive substring over the creator's name, display name and email.
93
+ */
94
+ export interface CreatorFilter {
95
+ emails?: string[];
96
+ match?: string;
97
+ }
90
98
  /**
91
99
  * Options for fetching issues
92
100
  */
@@ -96,6 +104,7 @@ export interface GetIssuesOptions {
96
104
  statusNames?: string[];
97
105
  labelNames?: string[];
98
106
  assigneeEmails?: string[];
107
+ creator?: CreatorFilter;
99
108
  excludeLabels?: string[];
100
109
  limit?: number;
101
110
  }
@@ -0,0 +1,4 @@
1
+ import type { Task } from "../utils.js";
2
+ import type { SourceIssue, TaskSourceType } from "./adapters/index.js";
3
+ /** Build a local Task from an issue fetched live from the source. */
4
+ export declare function sourceIssueToTask(issue: SourceIssue, sourceType: TaskSourceType): Task;
@@ -0,0 +1,30 @@
1
+ /** Build a local Task from an issue fetched live from the source. */
2
+ export function sourceIssueToTask(issue, sourceType) {
3
+ return {
4
+ id: issue.id,
5
+ linearId: issue.sourceId,
6
+ sourceId: issue.sourceId,
7
+ sourceType,
8
+ title: issue.title,
9
+ status: issue.status,
10
+ localStatus: "pending",
11
+ assignee: issue.assigneeEmail,
12
+ priority: issue.priority,
13
+ labels: issue.labels,
14
+ description: issue.description,
15
+ parentIssueId: issue.parentIssueId,
16
+ url: issue.url,
17
+ attachments: issue.attachments?.map((a) => ({
18
+ id: a.id,
19
+ title: a.title,
20
+ url: a.url,
21
+ sourceType: a.sourceType,
22
+ })),
23
+ comments: issue.comments?.map((c) => ({
24
+ id: c.id,
25
+ body: c.body,
26
+ createdAt: c.createdAt,
27
+ user: c.user,
28
+ })),
29
+ };
30
+ }
@@ -1,6 +1,7 @@
1
- import { createAdapter } from "./lib/adapters/index.js";
1
+ import { createAdapter, } from "./lib/adapters/index.js";
2
2
  import { displayTaskFull, getStatusIcon, PRIORITY_LABELS, } from "./lib/display.js";
3
3
  import { getStatusTransitions } from "./lib/linear.js";
4
+ import { sourceIssueToTask } from "./lib/remote-task.js";
4
5
  import { getSyncStatuses } from "./lib/status-helpers.js";
5
6
  import { getSourceType, getUserEmails, loadConfig, loadCycleData, loadLocalConfig, } from "./utils.js";
6
7
  function taskToMarkdown(task, includeLocal = true) {
@@ -106,33 +107,7 @@ async function fetchIssueFromRemote(issueId) {
106
107
  if (!issue) {
107
108
  return null;
108
109
  }
109
- return {
110
- id: issue.id,
111
- linearId: issue.sourceId,
112
- sourceId: issue.sourceId,
113
- sourceType,
114
- title: issue.title,
115
- status: issue.status,
116
- localStatus: "pending",
117
- assignee: issue.assigneeEmail,
118
- priority: issue.priority,
119
- labels: issue.labels,
120
- description: issue.description,
121
- parentIssueId: issue.parentIssueId,
122
- url: issue.url,
123
- attachments: issue.attachments?.map((a) => ({
124
- id: a.id,
125
- title: a.title,
126
- url: a.url,
127
- sourceType: a.sourceType,
128
- })),
129
- comments: issue.comments?.map((c) => ({
130
- id: c.id,
131
- body: c.body,
132
- createdAt: c.createdAt,
133
- user: c.user,
134
- })),
135
- };
110
+ return sourceIssueToTask(issue, sourceType);
136
111
  }
137
112
  function resolveAssigneeScope(requested, userEmails) {
138
113
  const emails = userEmails.map((e) => e.toLowerCase());
@@ -145,6 +120,31 @@ function resolveAssigneeScope(requested, userEmails) {
145
120
  }
146
121
  return { kind: "match", needle: value };
147
122
  }
123
+ /**
124
+ * Turn `--creator` into an API filter. "me" resolves to the configured
125
+ * current_user emails, an address matches exactly, anything else is a
126
+ * case-insensitive substring over the creator's name and email.
127
+ */
128
+ function resolveCreatorFilter(requested, userEmails) {
129
+ const value = requested.trim();
130
+ if (value.toLowerCase() === "me") {
131
+ return userEmails.length > 0
132
+ ? { emails: userEmails }
133
+ : {
134
+ error: '--creator me needs a current_user with an email. Run "ttt init" or pass an email/name.',
135
+ };
136
+ }
137
+ if (value.includes("@"))
138
+ return { emails: [value] };
139
+ return value ? { match: value } : { error: "--creator needs a value." };
140
+ }
141
+ /**
142
+ * Backlog tickets usually sit outside every cycle, so asking for a backlog
143
+ * status lifts the cycle scope instead of silently returning nothing.
144
+ */
145
+ function statusImpliesNoCycle(matched) {
146
+ return matched.some((s) => s.type === "backlog" || s.name.toLowerCase().includes("backlog"));
147
+ }
148
148
  /** True when the scope reaches outside what `ttt sync` stored locally. */
149
149
  function scopeEscapesLocalCache(scope, userEmails) {
150
150
  return userEmails.length > 0 && scope.kind !== "user";
@@ -195,7 +195,7 @@ function expandNames(needle, available) {
195
195
  const matches = available.filter((name) => name.toLowerCase().includes(lower));
196
196
  return matches.length > 0 ? matches : null;
197
197
  }
198
- async function searchIssuesFromRemote(filters, scope) {
198
+ async function searchIssuesFromRemote(filters, scope, creator) {
199
199
  const config = await loadConfig();
200
200
  const localConfig = await loadLocalConfig();
201
201
  const adapter = createAdapter(config);
@@ -210,12 +210,16 @@ async function searchIssuesFromRemote(filters, scope) {
210
210
  // the scope is what `ttt sync` stores, so an unfiltered --remote search
211
211
  // answers the same question as the unfiltered local one.
212
212
  let statusNames = getSyncStatuses(getStatusTransitions(config));
213
+ let noCycleScope = !!filters.anyCycle;
213
214
  if (filters.status) {
214
215
  const statuses = await adapter.getStatuses(teamId);
215
216
  const expanded = expandNames(filters.status, statuses.map((s) => s.name));
216
217
  if (!expanded)
217
218
  return [];
218
219
  statusNames = expanded;
220
+ if (statusImpliesNoCycle(statuses.filter((s) => expanded.includes(s.name)))) {
221
+ noCycleScope = true;
222
+ }
219
223
  }
220
224
  let labelNames;
221
225
  if (filters.label) {
@@ -227,10 +231,11 @@ async function searchIssuesFromRemote(filters, scope) {
227
231
  }
228
232
  const issues = await adapter.getIssues({
229
233
  teamId,
230
- cycleId: config.current_cycle?.id,
234
+ cycleId: noCycleScope ? undefined : config.current_cycle?.id,
231
235
  statusNames,
232
236
  labelNames,
233
237
  assigneeEmails: scope.kind === "user" ? scope.emails : undefined,
238
+ creator,
234
239
  });
235
240
  const tasks = issues.map((issue) => ({
236
241
  id: issue.id,
@@ -278,11 +283,16 @@ Options:
278
283
  --status <status> Filter by status
279
284
  --user <email> Filter by assignee (use "me" for yourself, "unassigned" for no assignee)
280
285
  --priority <n> Filter by priority (0=None, 1=Urgent, 2=High, 3=Medium, 4=Low)
286
+ --creator <who> Filter by creator: "me", an email, or a name (needs --remote,
287
+ Linear only). Without --user, the assignee scope is lifted so
288
+ tickets you created for others or left unassigned show up.
289
+ --any-cycle Search outside the current cycle (needs --remote)
281
290
 
282
291
  Searches are scoped the same way ttt sync is: current cycle, current_user,
283
292
  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.
293
+ --status Backlog also lifts the cycle scope, since backlog tickets usually
294
+ belong to no cycle. The local cache only holds current_user's issues, so
295
+ searching another assignee needs --remote.
286
296
 
287
297
  Examples:
288
298
  ttt show # Show all issues in local cycle data
@@ -293,6 +303,9 @@ Examples:
293
303
  ttt show --status "In Progress" --user me # My in-progress issues
294
304
  ttt show --priority 1 # Show all urgent issues
295
305
  ttt show --user tony --remote # Someone else's issues (needs --remote)
306
+ ttt show --remote --status Backlog --creator me # Backlog tickets I created
307
+ ttt show --remote --status Backlog --user me # Backlog tickets assigned to me
308
+ ttt show --remote --any-cycle --creator me # My tickets in any cycle
296
309
  ttt show --export # Export all issues as markdown`);
297
310
  process.exit(0);
298
311
  }
@@ -317,6 +330,18 @@ Examples:
317
330
  else if (arg === "--priority" && args[i + 1]) {
318
331
  filters.priority = parseInt(args[++i], 10);
319
332
  }
333
+ else if (arg === "--creator") {
334
+ const value = args[i + 1];
335
+ if (!value || value.startsWith("-")) {
336
+ console.error("--creator needs a value: me, an email, or a name.");
337
+ process.exit(1);
338
+ }
339
+ filters.creator = args[++i];
340
+ }
341
+ else if (arg === "--any-cycle") {
342
+ filters.anyCycle = true;
343
+ continue;
344
+ }
320
345
  else {
321
346
  continue;
322
347
  }
@@ -326,18 +351,46 @@ Examples:
326
351
  const issueId = args.find((arg, i) => !optionValues.has(i) &&
327
352
  !arg.startsWith("-") &&
328
353
  (arg.match(/^[A-Z]+-\d+$/i) || arg.match(/^[A-Za-z0-9]{8,}$/)));
354
+ const remoteOnlyFlag = filters.creator
355
+ ? "--creator"
356
+ : filters.anyCycle
357
+ ? "--any-cycle"
358
+ : undefined;
359
+ if (remoteOnlyFlag && (!useRemote || issueId)) {
360
+ console.error(issueId
361
+ ? `${remoteOnlyFlag} only applies to searches, not to a single issue.`
362
+ : `${remoteOnlyFlag} needs --remote: the local cache holds neither creators nor issues outside the cycle.`);
363
+ process.exit(1);
364
+ }
329
365
  // List mode: no specific issue ID. Filters are optional; --remote runs the
330
366
  // same query live instead of reading the cache.
331
367
  if (!issueId) {
332
368
  const userEmails = await getUserEmails();
333
- const scope = resolveAssigneeScope(filters.assignee, userEmails);
369
+ // A creator search is about tickets nobody may have assigned to the user,
370
+ // so it must not inherit the default current_user assignee scope.
371
+ const scope = filters.creator && !filters.assignee
372
+ ? { kind: "any" }
373
+ : resolveAssigneeScope(filters.assignee, userEmails);
334
374
  let tasks;
335
375
  if (useRemote) {
336
376
  const config = await loadConfig();
337
377
  const sourceType = getSourceType(config);
338
378
  const sourceName = sourceType === "trello" ? "Trello" : "Linear";
379
+ let creator;
380
+ if (filters.creator) {
381
+ if (sourceType === "trello") {
382
+ console.error("--creator is not supported for Trello: cards do not expose a creator.");
383
+ process.exit(1);
384
+ }
385
+ const resolved = resolveCreatorFilter(filters.creator, userEmails);
386
+ if ("error" in resolved) {
387
+ console.error(resolved.error);
388
+ process.exit(1);
389
+ }
390
+ creator = resolved;
391
+ }
339
392
  console.error(`Searching issues from ${sourceName}...`);
340
- tasks = await searchIssuesFromRemote(filters, scope);
393
+ tasks = await searchIssuesFromRemote(filters, scope, creator);
341
394
  }
342
395
  else {
343
396
  const data = await loadCycleData();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Preloaded (`bun --preload`) into CLI subprocesses so they talk to a fake
3
+ * Linear. Each GraphQL request is appended to MOCK_LINEAR_LOG as one JSON line
4
+ * `{ name, variables }`, which lets tests assert on what the CLI asked for.
5
+ */
6
+ import { appendFileSync } from "node:fs";
7
+ const STATES = [
8
+ { id: "s-backlog", name: "Backlog", type: "backlog" },
9
+ { id: "s-todo", name: "Todo", type: "unstarted" },
10
+ { id: "s-progress", name: "In Progress", type: "started" },
11
+ { id: "s-canceled", name: "Canceled", type: "canceled" },
12
+ ];
13
+ const ISSUE = {
14
+ __typename: "Issue",
15
+ id: "uuid-backlog-7",
16
+ identifier: "MP-7",
17
+ title: "Backlog follow-up",
18
+ priority: 3,
19
+ branchName: "mp-7",
20
+ url: "https://linear.app/x/issue/MP-7",
21
+ team: { id: "team-dev" },
22
+ // MOCK_LINEAR_ISSUE_STATE picks the workflow state the fake MP-7 sits in.
23
+ state: { id: process.env.MOCK_LINEAR_ISSUE_STATE ?? "s-backlog" },
24
+ labelIds: [],
25
+ previousIdentifiers: [],
26
+ reactionData: [],
27
+ reactions: [],
28
+ };
29
+ const page = (name, nodes) => ({
30
+ [name]: {
31
+ __typename: "Connection",
32
+ nodes,
33
+ pageInfo: { hasNextPage: false, endCursor: null },
34
+ },
35
+ });
36
+ function respond(query, variables = {}) {
37
+ if (query.includes("query workflowStates(")) {
38
+ return page("workflowStates", STATES.map((s) => ({ __typename: "WorkflowState", ...s })));
39
+ }
40
+ if (query.includes("query workflowState(")) {
41
+ return {
42
+ workflowState: {
43
+ __typename: "WorkflowState",
44
+ ...(STATES.find((s) => s.id === variables.id) ?? STATES[0]),
45
+ },
46
+ };
47
+ }
48
+ if (query.includes("query issues(")) {
49
+ return page("issues", [ISSUE]);
50
+ }
51
+ if (query.includes("query searchIssues(")) {
52
+ return {
53
+ searchIssues: {
54
+ __typename: "IssueSearchPayload",
55
+ totalCount: 1,
56
+ archivePayload: { __typename: "ArchiveResponse", archive: null },
57
+ pageInfo: { hasNextPage: false, endCursor: null },
58
+ nodes: [ISSUE],
59
+ },
60
+ };
61
+ }
62
+ if (query.includes("query issue("))
63
+ return { issue: ISSUE };
64
+ if (query.includes("query issueLabels("))
65
+ return page("issueLabels", []);
66
+ if (query.includes("mutation") && query.includes("issueUpdate")) {
67
+ return {
68
+ issueUpdate: { __typename: "IssuePayload", success: true, issue: ISSUE },
69
+ };
70
+ }
71
+ // Empty relation connections: `query issue_labels(`, `issue_comments(`, ...
72
+ const relation = /query issue_(\w+)\(/.exec(query)?.[1];
73
+ if (relation)
74
+ return { issue: page(relation, []) };
75
+ throw new Error(`Unmocked Linear query: ${query.slice(0, 80)}`);
76
+ }
77
+ globalThis.fetch = (async (_input, init) => {
78
+ const body = JSON.parse(String(init?.body));
79
+ const name = /(?:query|mutation)\s+(\w+)/.exec(body.query)?.[1];
80
+ if (process.env.MOCK_LINEAR_LOG) {
81
+ appendFileSync(process.env.MOCK_LINEAR_LOG, `${JSON.stringify({ name, variables: body.variables })}\n`);
82
+ }
83
+ return new Response(JSON.stringify({ data: respond(String(body.query), body.variables) }), {
84
+ status: 200,
85
+ headers: { "Content-Type": "application/json" },
86
+ });
87
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "team-toon-tack",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "Linear & Trello task sync & management CLI with TOON format",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,9 +44,12 @@ ttt sync MP-624 # Sync specific issue
44
44
  ttt show # List all local issues
45
45
  ttt show MP-624 # Show issue details
46
46
  ttt show --user me # My issues
47
+ ttt show --remote --status Backlog --creator me # Backlog tickets I created (any cycle)
48
+ ttt show --remote --status Backlog --user me # Backlog tickets assigned to me
47
49
  ttt claim next # Claim highest priority pending task
48
50
  ttt claim next 3 # Claim top 3 pending tasks
49
51
  ttt claim MP-624 MP-625 # Batch claim (alias: ttt work-on)
52
+ ttt claim MP-700 # Not in local data (e.g. Backlog)? Fetched from remote, then claimed
50
53
  ttt create # Create new issue (interactive)
51
54
  ttt create -t "Title" -p 2 # Quick create with flags
52
55
  ttt assign MP-624 -a john # Reassign issue