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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +22 -1
- package/README.zh-TW.md +14 -1
- package/commands/ttt-show.md +17 -0
- package/commands/ttt-work-on.md +6 -2
- package/dist/scripts/claim.js +69 -10
- package/dist/scripts/lib/adapters/linear-adapter.js +15 -0
- package/dist/scripts/lib/adapters/trello-adapter.js +3 -0
- package/dist/scripts/lib/adapters/types.d.ts +9 -0
- package/dist/scripts/lib/remote-task.d.ts +4 -0
- package/dist/scripts/lib/remote-task.js +30 -0
- package/dist/scripts/show.js +87 -34
- package/dist/scripts/test-support/linear-mock.d.ts +1 -0
- package/dist/scripts/test-support/linear-mock.js +87 -0
- package/package.json +1 -1
- package/skills/managing-linear-tasks/SKILL.md +3 -0
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
|
|
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
|
-
|
|
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
|
配置設定。
|
package/commands/ttt-show.md
CHANGED
|
@@ -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.
|
package/commands/ttt-work-on.md
CHANGED
|
@@ -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
|
|
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
|
|
package/dist/scripts/claim.js
CHANGED
|
@@ -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
|
-
/**
|
|
92
|
-
|
|
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
|
|
97
|
-
if (
|
|
98
|
-
tasks.push(
|
|
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
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
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
|
+
}
|
package/dist/scripts/show.js
CHANGED
|
@@ -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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|