team-toon-tack 3.10.3 → 3.11.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 +6 -2
- package/README.zh-TW.md +6 -2
- package/commands/ttt-create.md +1 -1
- package/commands/ttt-done.md +16 -5
- package/dist/scripts/create.d.ts +8 -0
- package/dist/scripts/create.js +36 -6
- package/dist/scripts/done-job.js +22 -10
- package/dist/scripts/lib/adapters/linear-adapter.js +6 -1
- package/dist/scripts/lib/display.d.ts +9 -2
- package/dist/scripts/lib/display.js +13 -3
- package/dist/scripts/lib/done/args.js +23 -4
- package/dist/scripts/lib/done/linear-handler.d.ts +12 -0
- package/dist/scripts/lib/done/linear-handler.js +30 -7
- package/dist/scripts/lib/done/status-check.d.ts +21 -0
- package/dist/scripts/lib/done/status-check.js +37 -0
- package/dist/scripts/lib/done/types.d.ts +4 -0
- package/dist/scripts/lib/git.d.ts +13 -1
- package/dist/scripts/lib/git.js +33 -19
- package/dist/scripts/show.js +7 -5
- package/package.json +1 -1
- package/skills/managing-linear-tasks/SKILL.md +2 -0
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ During init, you'll be prompted to select your task source (Linear or Trello) an
|
|
|
58
58
|
|------|----------|
|
|
59
59
|
| `simple` | Mark task as Done + parent as Done. Default when no QA team configured. |
|
|
60
60
|
| `strict_review` | Mark task to dev testing + parent to QA testing. |
|
|
61
|
-
| `upstream_strict` | Mark task as Done + parent to Testing. Falls back to dev testing if no parent. Default when QA team configured. |
|
|
61
|
+
| `upstream_strict` | Mark task as Done + parent to Testing. Falls back to dev testing if no parent (the task then goes straight to testing, without a Done step). Default when QA team configured. |
|
|
62
62
|
| `upstream_not_strict` | Mark task as Done + parent to Testing. No fallback if no parent. |
|
|
63
63
|
|
|
64
64
|
> **Note:** Trello always uses simple completion mode as it doesn't support parent issues.
|
|
@@ -181,9 +181,13 @@ ttt done # Auto-select if only one in-progress
|
|
|
181
181
|
ttt done MP-123 # Specific issue
|
|
182
182
|
ttt done -m "Fixed the bug" # With completion message
|
|
183
183
|
ttt done MP-123 --from-remote # Fetch from remote (bypasses local data check)
|
|
184
|
+
ttt done MP-123 --commit 1a2b3c4 # Record this commit instead of HEAD
|
|
185
|
+
ttt done MP-123 --commit 1a2b3c4 --repo ~/projects/app # ...read from another repository
|
|
184
186
|
```
|
|
185
187
|
|
|
186
|
-
Use `--from-remote` (or `-r`) when the issue exists in remote but not in local sync data.
|
|
188
|
+
Use `--from-remote` (or `-r`) when the issue exists in remote but not in local sync data, or when the ticket is still Todo and you want to complete it without `ttt claim` (no extra In Progress transition). `ttt done` without it only accepts in-progress tasks.
|
|
189
|
+
|
|
190
|
+
Use `--commit <sha>` (and `--repo <path>` when the commit lives in another checkout) to close a ticket whose commit was merged earlier. Without it, the current HEAD is recorded.
|
|
187
191
|
|
|
188
192
|
### `ttt status`
|
|
189
193
|
|
package/README.zh-TW.md
CHANGED
|
@@ -58,7 +58,7 @@ ttt init
|
|
|
58
58
|
|------|------|
|
|
59
59
|
| `simple` | 任務標記為 Done,parent 也標記為 Done。未設定 QA 團隊時的預設值。 |
|
|
60
60
|
| `strict_review` | 任務標記到開發團隊的 testing 狀態,parent 標記到 QA 團隊的 testing 狀態。 |
|
|
61
|
-
| `upstream_strict` | 任務標記為 Done,parent 移動到 Testing。若無 parent,fallback 到開發團隊的 testing
|
|
61
|
+
| `upstream_strict` | 任務標記為 Done,parent 移動到 Testing。若無 parent,fallback 到開發團隊的 testing 狀態(此時任務直接進 testing,不會先寫 Done)。設定 QA 團隊時的預設值。 |
|
|
62
62
|
| `upstream_not_strict` | 任務標記為 Done,parent 移動到 Testing。若無 parent 不做 fallback。 |
|
|
63
63
|
|
|
64
64
|
> **注意**:Trello 因不支援 parent issue,一律使用簡單完成模式。
|
|
@@ -181,9 +181,13 @@ ttt done # 若只有一個進行中,自動選擇
|
|
|
181
181
|
ttt done MP-123 # 指定 issue
|
|
182
182
|
ttt done -m "修復了錯誤" # 附上完成說明
|
|
183
183
|
ttt done MP-123 --from-remote # 從遠端取得(略過本地資料檢查)
|
|
184
|
+
ttt done MP-123 --commit 1a2b3c4 # 記錄指定 commit,而非目前 HEAD
|
|
185
|
+
ttt done MP-123 --commit 1a2b3c4 --repo ~/projects/app # ...從另一個 repository 讀取
|
|
184
186
|
```
|
|
185
187
|
|
|
186
|
-
當 issue
|
|
188
|
+
當 issue 存在於遠端但未同步到本地資料,或 ticket 仍是 Todo、想不經 `ttt claim`(不多一次 In Progress 轉換)直接完成時,使用 `--from-remote`(或 `-r`)。不加此參數時,`ttt done` 只接受進行中的任務。
|
|
189
|
+
|
|
190
|
+
要關閉 commit 早已合併的 ticket,用 `--commit <sha>`(commit 在另一個 checkout 時再加 `--repo <path>`)。未指定時記錄目前的 HEAD。
|
|
187
191
|
|
|
188
192
|
### `ttt status`
|
|
189
193
|
|
package/commands/ttt-create.md
CHANGED
|
@@ -45,7 +45,7 @@ Options:
|
|
|
45
45
|
-d, --description <text> Description
|
|
46
46
|
-a, --assignee <key> Assignee user key from config
|
|
47
47
|
-p, --priority <0-4> Priority (0=none, 1=urgent, 2=high, 3=medium, 4=low)
|
|
48
|
-
-l, --label <names> Label names (comma-separated)
|
|
48
|
+
-l, --label <names> Label names (comma-separated; config first, then looked up live in Linear)
|
|
49
49
|
-s, --status <name> Initial status name
|
|
50
50
|
--parent <id> Parent issue identifier (e.g., MP-100)
|
|
51
51
|
--no-interactive Skip interactive prompts
|
package/commands/ttt-done.md
CHANGED
|
@@ -11,6 +11,12 @@ arguments:
|
|
|
11
11
|
- name: from-remote
|
|
12
12
|
description: Fetch issue from Linear (bypass local data check)
|
|
13
13
|
required: false
|
|
14
|
+
- name: commit
|
|
15
|
+
description: Commit to record instead of HEAD (e.g., an already merged commit)
|
|
16
|
+
required: false
|
|
17
|
+
- name: repo
|
|
18
|
+
description: Git repository to read the commit from (default - cwd)
|
|
19
|
+
required: false
|
|
14
20
|
---
|
|
15
21
|
|
|
16
22
|
<law>
|
|
@@ -24,7 +30,7 @@ DO NOT skip this command — it updates both local and remote status, posts a co
|
|
|
24
30
|
## Execution
|
|
25
31
|
|
|
26
32
|
```bash
|
|
27
|
-
ttt done {{ issue-id }} {{ "-m \"" + message + "\"" if message }} {{ "--from-remote" if from-remote }}
|
|
33
|
+
ttt done {{ issue-id }} {{ "-m \"" + message + "\"" if message }} {{ "--from-remote" if from-remote }} {{ "--commit " + commit if commit }} {{ "--repo " + repo if repo }}
|
|
28
34
|
```
|
|
29
35
|
|
|
30
36
|
### Common Examples
|
|
@@ -34,20 +40,24 @@ ttt done # Complete current in-progress task
|
|
|
34
40
|
ttt done MP-624 # Complete specific task
|
|
35
41
|
ttt done -m "Fixed null check" # With completion message
|
|
36
42
|
ttt done MP-624 -m "Refactored auth" # Specific task with message
|
|
37
|
-
ttt done MP-624 --from-remote # Issue not in local data
|
|
43
|
+
ttt done MP-624 --from-remote # Issue not in local data, or still Todo (no claim needed)
|
|
44
|
+
ttt done MP-624 --commit 1a2b3c4 # Record an earlier commit, not HEAD
|
|
38
45
|
```
|
|
39
46
|
|
|
40
47
|
## Full CLI Reference
|
|
41
48
|
|
|
42
49
|
```
|
|
43
|
-
Usage: ttt done [issue-id] [-m message] [--from-remote]
|
|
50
|
+
Usage: ttt done [issue-id] [-m message] [--from-remote] [--commit <sha>] [--repo <path>]
|
|
44
51
|
|
|
45
52
|
Arguments:
|
|
46
53
|
issue-id Optional. Issue ID if multiple tasks in-progress
|
|
47
54
|
|
|
48
55
|
Options:
|
|
49
56
|
-m, --message Completion message describing what was done
|
|
50
|
-
-r, --from-remote Fetch issue from Linear (bypass local data check
|
|
57
|
+
-r, --from-remote Fetch issue from Linear (bypass local data check;
|
|
58
|
+
also completes a Todo ticket without `ttt claim`)
|
|
59
|
+
--commit <sha> Record this commit instead of HEAD
|
|
60
|
+
--repo <path> Git repository to read the commit from (default: cwd)
|
|
51
61
|
```
|
|
52
62
|
|
|
53
63
|
## Before Running
|
|
@@ -64,7 +74,7 @@ Based on configured completion mode:
|
|
|
64
74
|
|------|-------------|---------------|
|
|
65
75
|
| `simple` | → Done | → Done |
|
|
66
76
|
| `strict_review` | → Testing | → QA Testing |
|
|
67
|
-
| `upstream_strict` | → Done | → Testing |
|
|
77
|
+
| `upstream_strict` | → Done | → Testing (no valid parent: task goes straight to Testing) |
|
|
68
78
|
| `upstream_not_strict` | → Done | → Testing (no fallback) |
|
|
69
79
|
|
|
70
80
|
Additionally:
|
|
@@ -79,4 +89,5 @@ Additionally:
|
|
|
79
89
|
| `No in-progress task` | Specify issue-id explicitly |
|
|
80
90
|
| `Multiple in-progress` | Specify issue-id to disambiguate |
|
|
81
91
|
| `Issue not found` | Use `--from-remote` flag |
|
|
92
|
+
| `不在進行中狀態` (task is Todo) | `ttt claim <id>` first, or `ttt done <id> --from-remote` |
|
|
82
93
|
| `No commits found` | Commit your changes first |
|
package/dist/scripts/create.d.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import type { SourceLabel } from "./lib/adapters/types.js";
|
|
3
|
+
import { type Config } from "./utils.js";
|
|
4
|
+
/**
|
|
5
|
+
* Resolve `-l` names to label ids. Config is checked first (key or display
|
|
6
|
+
* name); anything it does not know is looked up live in the source, so a
|
|
7
|
+
* label added after `ttt init` still works.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveLabelIds(config: Config, labelArg: string | undefined, fetchSourceLabels: () => Promise<SourceLabel[]>): Promise<string[] | undefined>;
|
|
2
10
|
export declare function create(args?: string[]): Promise<void>;
|
package/dist/scripts/create.js
CHANGED
|
@@ -95,20 +95,50 @@ async function resolveStatusId(config, adapter, teamId, statusName) {
|
|
|
95
95
|
}
|
|
96
96
|
return target.id;
|
|
97
97
|
}
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Resolve `-l` names to label ids. Config is checked first (key or display
|
|
100
|
+
* name); anything it does not know is looked up live in the source, so a
|
|
101
|
+
* label added after `ttt init` still works.
|
|
102
|
+
*/
|
|
103
|
+
export async function resolveLabelIds(config, labelArg, fetchSourceLabels) {
|
|
104
|
+
if (!labelArg)
|
|
100
105
|
return undefined;
|
|
101
|
-
const names = labelArg
|
|
106
|
+
const names = labelArg
|
|
107
|
+
.split(",")
|
|
108
|
+
.map((n) => n.trim())
|
|
109
|
+
.filter(Boolean);
|
|
102
110
|
const ids = [];
|
|
111
|
+
const unresolved = [];
|
|
103
112
|
for (const name of names) {
|
|
104
113
|
const lower = name.toLowerCase();
|
|
105
114
|
// Match by config key (e.g. "backend_v3") OR display name (e.g. "Backend-v3")
|
|
106
|
-
const entry = Object.entries(config.labels).find(([key, l]) => key.toLowerCase() === lower || l.name.toLowerCase() === lower);
|
|
115
|
+
const entry = Object.entries(config.labels ?? {}).find(([key, l]) => key.toLowerCase() === lower || l.name.toLowerCase() === lower);
|
|
107
116
|
if (entry) {
|
|
108
117
|
ids.push(entry[1].id);
|
|
109
118
|
}
|
|
110
119
|
else {
|
|
111
|
-
|
|
120
|
+
unresolved.push(name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (unresolved.length > 0) {
|
|
124
|
+
let sourceLabels;
|
|
125
|
+
try {
|
|
126
|
+
sourceLabels = await fetchSourceLabels();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Fall through to the config-only warning below
|
|
130
|
+
}
|
|
131
|
+
for (const name of unresolved) {
|
|
132
|
+
const found = sourceLabels?.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
|
133
|
+
if (found) {
|
|
134
|
+
ids.push(found.id);
|
|
135
|
+
}
|
|
136
|
+
else if (sourceLabels) {
|
|
137
|
+
console.error(`Warning: label "${name}" not found in config or in the source, skipping. Available: ${sourceLabels.map((l) => l.name).join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
console.error(`Warning: label "${name}" not found in config (source lookup failed), skipping.`);
|
|
141
|
+
}
|
|
112
142
|
}
|
|
113
143
|
}
|
|
114
144
|
return ids.length > 0 ? ids : undefined;
|
|
@@ -216,7 +246,7 @@ Examples:
|
|
|
216
246
|
}
|
|
217
247
|
const assigneeId = resolveAssigneeId(config, assigneeKey);
|
|
218
248
|
const statusId = await resolveStatusId(config, adapter, teamId, sourceType === "trello" && !statusName ? "todo" : statusName);
|
|
219
|
-
const labelIds = resolveLabelIds(config, labelArg);
|
|
249
|
+
const labelIds = await resolveLabelIds(config, labelArg, () => adapter.getLabels(teamId));
|
|
220
250
|
const parentSourceId = await resolveParentSourceId(adapter, parentId);
|
|
221
251
|
const currentCycle = await adapter.getCurrentCycle(teamId);
|
|
222
252
|
const options = {
|
package/dist/scripts/done-job.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { input, select } from "@inquirer/prompts";
|
|
7
7
|
import { handleLinearCompletion, handleTrelloCompletion, parseArgs, printHelp, } from "./lib/done/index.js";
|
|
8
|
+
import { describeNotInProgress, syncUntilStatus, } from "./lib/done/status-check.js";
|
|
8
9
|
import { getLatestCommit } from "./lib/git.js";
|
|
9
10
|
import { fetchIssueDetail, syncSingleIssue } from "./lib/sync.js";
|
|
10
11
|
import { findTaskByIssueId, getSourceType, loadConfig, loadCycleData, loadLocalConfig, saveCycleData, } from "./utils.js";
|
|
@@ -14,7 +15,7 @@ async function doneJob() {
|
|
|
14
15
|
printHelp();
|
|
15
16
|
process.exit(0);
|
|
16
17
|
}
|
|
17
|
-
const { issueId: argIssueId, message: argMessage, fromRemote, } = parseArgs(args);
|
|
18
|
+
const { issueId: argIssueId, message: argMessage, fromRemote, commit: commitRef, repo, } = parseArgs(args);
|
|
18
19
|
let issueId = argIssueId;
|
|
19
20
|
const config = await loadConfig();
|
|
20
21
|
const localConfig = await loadLocalConfig();
|
|
@@ -45,7 +46,9 @@ async function doneJob() {
|
|
|
45
46
|
}
|
|
46
47
|
// Find in-progress tasks
|
|
47
48
|
const inProgressTasks = data.tasks.filter((t) => t.localStatus === "in-progress");
|
|
48
|
-
|
|
49
|
+
// With an explicit issue ID, fall through so the status guard below can
|
|
50
|
+
// say why that ticket cannot be completed
|
|
51
|
+
if (inProgressTasks.length === 0 && !issueId) {
|
|
49
52
|
console.log("沒有進行中的任務");
|
|
50
53
|
process.exit(0);
|
|
51
54
|
}
|
|
@@ -87,13 +90,17 @@ async function doneJob() {
|
|
|
87
90
|
process.exit(1);
|
|
88
91
|
}
|
|
89
92
|
if (localTask.localStatus !== "in-progress") {
|
|
90
|
-
console.
|
|
93
|
+
console.error(describeNotInProgress(localTask));
|
|
91
94
|
process.exit(1);
|
|
92
95
|
}
|
|
93
96
|
task = localTask;
|
|
94
97
|
}
|
|
95
|
-
// Get
|
|
96
|
-
const commit = getLatestCommit();
|
|
98
|
+
// Get the commit to record: HEAD by default, or the one named with --commit
|
|
99
|
+
const commit = getLatestCommit({ ref: commitRef, cwd: repo });
|
|
100
|
+
if ((commitRef || repo) && !commit) {
|
|
101
|
+
console.error(`Cannot read commit ${commitRef ?? "HEAD"} from ${repo ?? "the current directory"}. Nothing was changed.`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
97
104
|
// Get AI summary message
|
|
98
105
|
let promptMessage = argMessage || "";
|
|
99
106
|
if (!promptMessage && process.stdin.isTTY) {
|
|
@@ -135,16 +142,18 @@ async function doneJob() {
|
|
|
135
142
|
}
|
|
136
143
|
// Sync full issue data from remote (including new comment)
|
|
137
144
|
if (sourceType === "linear") {
|
|
138
|
-
|
|
145
|
+
// Let remote status determine local status; wait out read-after-write lag
|
|
146
|
+
// so a stale read is not recorded as the local state
|
|
147
|
+
const syncedTask = await syncUntilStatus(() => syncSingleIssue(task.id, {
|
|
139
148
|
config,
|
|
140
149
|
localConfig,
|
|
141
|
-
preserveLocalStatus: false,
|
|
142
|
-
});
|
|
150
|
+
preserveLocalStatus: false,
|
|
151
|
+
}), completionResult?.status);
|
|
143
152
|
if (syncedTask) {
|
|
144
153
|
console.log(`Synced: ${syncedTask.id} → ${syncedTask.status} (local: ${syncedTask.localStatus})`);
|
|
145
154
|
if (completionResult?.status &&
|
|
146
155
|
syncedTask.status !== completionResult.status) {
|
|
147
|
-
console.error(`Remote status verification failed for ${task.id}: expected ${completionResult.status}, got ${syncedTask.status}.`);
|
|
156
|
+
console.error(`Remote status verification failed for ${task.id}: expected ${completionResult.status}, got ${syncedTask.status}. Another actor (e.g. a Git integration or automation) may have changed it; check the issue in Linear.`);
|
|
148
157
|
process.exit(1);
|
|
149
158
|
}
|
|
150
159
|
}
|
|
@@ -177,4 +186,7 @@ async function doneJob() {
|
|
|
177
186
|
}
|
|
178
187
|
console.log(`\n🎉 任務完成!`);
|
|
179
188
|
}
|
|
180
|
-
doneJob().catch(
|
|
189
|
+
doneJob().catch((error) => {
|
|
190
|
+
console.error(error instanceof Error ? error.message : error);
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
});
|
|
@@ -54,8 +54,13 @@ export class LinearAdapter {
|
|
|
54
54
|
}));
|
|
55
55
|
}
|
|
56
56
|
async getLabels(teamId) {
|
|
57
|
+
// Workspace-level labels have no team, so a team-only filter misses them
|
|
58
|
+
// even though they can be attached to this team's issues
|
|
57
59
|
const labelsData = await this.client.issueLabels({
|
|
58
|
-
filter: {
|
|
60
|
+
filter: {
|
|
61
|
+
or: [{ team: { id: { eq: teamId } } }, { team: { null: true } }],
|
|
62
|
+
},
|
|
63
|
+
first: 250,
|
|
59
64
|
});
|
|
60
65
|
return labelsData.nodes.map((label) => ({
|
|
61
66
|
id: label.id,
|
|
@@ -2,11 +2,18 @@ import type { Task } from "../utils.js";
|
|
|
2
2
|
export declare const PRIORITY_LABELS: Record<number, string>;
|
|
3
3
|
export declare function getStatusIcon(localStatus: Task["localStatus"]): string;
|
|
4
4
|
export declare function displayTaskHeader(task: Task, icon?: string): void;
|
|
5
|
-
export
|
|
5
|
+
export interface TaskDisplayOptions {
|
|
6
|
+
/** Print the source status (and local status when `local` is set) and assignee even if empty */
|
|
7
|
+
status?: {
|
|
8
|
+
local: boolean;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export declare function formatStatusLine(task: Task, includeLocal: boolean): string;
|
|
12
|
+
export declare function displayTaskInfo(task: Task, options?: TaskDisplayOptions): void;
|
|
6
13
|
export declare function displayTaskStatus(task: Task): void;
|
|
7
14
|
export declare function displayTaskDescription(task: Task): void;
|
|
8
15
|
export declare function displayTaskAttachments(task: Task): void;
|
|
9
16
|
export declare function displayTaskComments(task: Task): void;
|
|
10
17
|
export declare function displayTaskFooter(): void;
|
|
11
|
-
export declare function displayTaskFull(task: Task, icon?: string): void;
|
|
18
|
+
export declare function displayTaskFull(task: Task, icon?: string, options?: TaskDisplayOptions): void;
|
|
12
19
|
export declare function displayTaskWithStatus(task: Task): void;
|
|
@@ -25,11 +25,21 @@ export function displayTaskHeader(task, icon) {
|
|
|
25
25
|
console.log(`${icon || getStatusIcon(task.localStatus)} ${task.id}: ${task.title}`);
|
|
26
26
|
console.log(separator);
|
|
27
27
|
}
|
|
28
|
-
export function
|
|
28
|
+
export function formatStatusLine(task, includeLocal) {
|
|
29
|
+
return includeLocal
|
|
30
|
+
? `Status: ${task.status} (Local: ${task.localStatus})`
|
|
31
|
+
: `Status: ${task.status}`;
|
|
32
|
+
}
|
|
33
|
+
export function displayTaskInfo(task, options = {}) {
|
|
34
|
+
if (options.status) {
|
|
35
|
+
console.log(formatStatusLine(task, options.status.local));
|
|
36
|
+
}
|
|
29
37
|
console.log(`Priority: ${PRIORITY_LABELS[task.priority] || "None"}`);
|
|
30
38
|
console.log(`Labels: ${task.labels.join(", ")}`);
|
|
31
39
|
if (task.assignee)
|
|
32
40
|
console.log(`Assignee: ${task.assignee}`);
|
|
41
|
+
else if (options.status)
|
|
42
|
+
console.log("Assignee: Unassigned");
|
|
33
43
|
if (task.estimate) {
|
|
34
44
|
const suffix = task.estimate.note ? ` (${task.estimate.note})` : "";
|
|
35
45
|
console.log(`Estimate: ${task.estimate.hours}h${suffix}`);
|
|
@@ -82,9 +92,9 @@ export function displayTaskComments(task) {
|
|
|
82
92
|
export function displayTaskFooter() {
|
|
83
93
|
console.log(`\n${"─".repeat(50)}`);
|
|
84
94
|
}
|
|
85
|
-
export function displayTaskFull(task, icon) {
|
|
95
|
+
export function displayTaskFull(task, icon, options = {}) {
|
|
86
96
|
displayTaskHeader(task, icon);
|
|
87
|
-
displayTaskInfo(task);
|
|
97
|
+
displayTaskInfo(task, options);
|
|
88
98
|
displayTaskDescription(task);
|
|
89
99
|
displayTaskAttachments(task);
|
|
90
100
|
displayTaskComments(task);
|
|
@@ -5,6 +5,8 @@ export function parseArgs(args) {
|
|
|
5
5
|
let issueId;
|
|
6
6
|
let message;
|
|
7
7
|
let fromRemote = false;
|
|
8
|
+
let commit;
|
|
9
|
+
let repo;
|
|
8
10
|
for (let i = 0; i < args.length; i++) {
|
|
9
11
|
const arg = args[i];
|
|
10
12
|
if (arg === "-m" || arg === "--message") {
|
|
@@ -13,14 +15,24 @@ export function parseArgs(args) {
|
|
|
13
15
|
else if (arg === "--from-remote" || arg === "-r") {
|
|
14
16
|
fromRemote = true;
|
|
15
17
|
}
|
|
18
|
+
else if (arg === "--commit" || arg === "--repo") {
|
|
19
|
+
const value = args[++i];
|
|
20
|
+
if (!value || value.startsWith("-")) {
|
|
21
|
+
throw new Error(`${arg} requires a value.`);
|
|
22
|
+
}
|
|
23
|
+
if (arg === "--commit")
|
|
24
|
+
commit = value;
|
|
25
|
+
else
|
|
26
|
+
repo = value;
|
|
27
|
+
}
|
|
16
28
|
else if (!arg.startsWith("-")) {
|
|
17
29
|
issueId = arg;
|
|
18
30
|
}
|
|
19
31
|
}
|
|
20
|
-
return { issueId, message, fromRemote };
|
|
32
|
+
return { issueId, message, fromRemote, commit, repo };
|
|
21
33
|
}
|
|
22
34
|
export function printHelp() {
|
|
23
|
-
console.log(`Usage: ttt done [issue-id] [-m message] [--from-remote]
|
|
35
|
+
console.log(`Usage: ttt done [issue-id] [-m message] [--from-remote] [--commit <sha>] [--repo <path>]
|
|
24
36
|
|
|
25
37
|
Arguments:
|
|
26
38
|
issue-id Issue ID (e.g., MP-624). Optional if only one task is in-progress
|
|
@@ -28,12 +40,19 @@ Arguments:
|
|
|
28
40
|
Options:
|
|
29
41
|
-m, --message AI summary message describing the fix
|
|
30
42
|
-r, --from-remote Fetch issue directly from Linear (bypasses local data check)
|
|
31
|
-
Use when issue exists in Linear but not in local sync data
|
|
43
|
+
Use when issue exists in Linear but not in local sync data.
|
|
44
|
+
Also completes a ticket that is still Todo without 'ttt claim'
|
|
45
|
+
(no extra In Progress transition)
|
|
46
|
+
--commit <sha> Record this commit instead of the current HEAD
|
|
47
|
+
(for closing a ticket whose commit was merged earlier)
|
|
48
|
+
--repo <path> Git repository to read the commit from (default: cwd)
|
|
32
49
|
|
|
33
50
|
Examples:
|
|
34
51
|
ttt done # Complete current in-progress task
|
|
35
52
|
ttt done MP-624 # Complete specific task
|
|
36
53
|
ttt done -m "Fixed null check" # With completion message
|
|
37
54
|
ttt done MP-624 -m "Refactored" # Specific task with message
|
|
38
|
-
ttt done MP-624 --from-remote # Complete issue fetched directly from Linear
|
|
55
|
+
ttt done MP-624 --from-remote # Complete issue fetched directly from Linear
|
|
56
|
+
ttt done MP-624 --commit 1a2b3c4 # Record an earlier commit, not HEAD
|
|
57
|
+
ttt done MP-624 --commit 1a2b3c4 --repo ~/projects/app`);
|
|
39
58
|
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Linear completion handler
|
|
3
3
|
*/
|
|
4
|
+
import { updateIssueStatus } from "../linear.js";
|
|
5
|
+
import { updateParentToTesting } from "./parent-issue.js";
|
|
4
6
|
import type { CompletionContext, CompletionResult } from "./types.js";
|
|
7
|
+
/** Linear calls the completion handlers make; injectable so tests need no network. */
|
|
8
|
+
export interface CompletionDeps {
|
|
9
|
+
updateIssueStatus: typeof updateIssueStatus;
|
|
10
|
+
updateParentToTesting: typeof updateParentToTesting;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Handle upstream completion modes (upstream_strict and upstream_not_strict)
|
|
14
|
+
* Mark as done, then update parent to testing
|
|
15
|
+
*/
|
|
16
|
+
export declare function handleUpstreamCompletion(context: CompletionContext, isStrict: boolean, deps?: CompletionDeps): Promise<CompletionResult>;
|
|
5
17
|
/**
|
|
6
18
|
* Handle Linear task completion with complex completion modes
|
|
7
19
|
*/
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
import { buildCompletionComment } from "../git.js";
|
|
5
5
|
import { addComment, getStatusTransitions, updateIssueStatus, } from "../linear.js";
|
|
6
6
|
import { updateParentStatus, updateParentToTesting } from "./parent-issue.js";
|
|
7
|
+
const defaultDeps = {
|
|
8
|
+
updateIssueStatus,
|
|
9
|
+
updateParentToTesting,
|
|
10
|
+
};
|
|
11
|
+
const STRICT_FALLBACK_HINT = 'completion_mode is upstream_strict; set it to "upstream_not_strict" or "simple" (ttt config) to keep Done';
|
|
7
12
|
function logParentUnchanged(parentIssueId, unfinishedChildren) {
|
|
8
13
|
console.log(`Linear: Parent ${parentIssueId} unchanged (unfinished sub-issues: ${unfinishedChildren.join(", ")})`);
|
|
9
14
|
}
|
|
@@ -90,13 +95,31 @@ async function handleStrictReview(context) {
|
|
|
90
95
|
* Handle upstream completion modes (upstream_strict and upstream_not_strict)
|
|
91
96
|
* Mark as done, then update parent to testing
|
|
92
97
|
*/
|
|
93
|
-
async function handleUpstreamCompletion(context, isStrict) {
|
|
98
|
+
export async function handleUpstreamCompletion(context, isStrict, deps = defaultDeps) {
|
|
94
99
|
const { task, config, localConfig } = context;
|
|
95
100
|
const transitions = getStatusTransitions(config);
|
|
96
101
|
// Get the testing status to use (from dev team)
|
|
97
102
|
const devTestingStatus = localConfig.dev_testing_status || transitions.testing;
|
|
103
|
+
const canUpdateParent = Boolean(task.parentIssueId && localConfig.qa_pm_teams?.length);
|
|
104
|
+
// upstream_strict with nothing to hand off to ends in testing anyway, so go
|
|
105
|
+
// there directly instead of writing Done and overwriting it a moment later
|
|
106
|
+
if (isStrict && !canUpdateParent && devTestingStatus) {
|
|
107
|
+
const reason = task.parentIssueId
|
|
108
|
+
? "qa_pm_teams is not configured"
|
|
109
|
+
: "no parent issue";
|
|
110
|
+
const success = await deps.updateIssueStatus(task.linearId, devTestingStatus, config, localConfig.team);
|
|
111
|
+
if (success) {
|
|
112
|
+
console.log(`Linear: ${task.id} → ${devTestingStatus} (${reason}; ${STRICT_FALLBACK_HINT})`);
|
|
113
|
+
return { success: true, status: devTestingStatus };
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
success: false,
|
|
117
|
+
status: task.status,
|
|
118
|
+
message: `Failed to move ${task.id} to ${devTestingStatus}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
98
121
|
// First, mark as done
|
|
99
|
-
const doneSuccess = await updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
|
|
122
|
+
const doneSuccess = await deps.updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
|
|
100
123
|
if (doneSuccess) {
|
|
101
124
|
console.log(`Linear: ${task.id} → ${transitions.done}`);
|
|
102
125
|
}
|
|
@@ -105,8 +128,8 @@ async function handleUpstreamCompletion(context, isStrict) {
|
|
|
105
128
|
let parentTestingStatus;
|
|
106
129
|
// Parent is valid but waits for its other sub-issues; it moves when the last one completes
|
|
107
130
|
let parentAwaitingSiblings = false;
|
|
108
|
-
if (task.parentIssueId &&
|
|
109
|
-
const result = await updateParentToTesting(task.parentIssueId, task.id, localConfig.qa_pm_teams, config, devTestingStatus);
|
|
131
|
+
if (task.parentIssueId && canUpdateParent) {
|
|
132
|
+
const result = await deps.updateParentToTesting(task.parentIssueId, task.id, localConfig.qa_pm_teams ?? [], config, devTestingStatus);
|
|
110
133
|
parentUpdateSuccess = result.success;
|
|
111
134
|
parentTestingStatus = result.testingStatus;
|
|
112
135
|
if (parentUpdateSuccess) {
|
|
@@ -122,10 +145,10 @@ async function handleUpstreamCompletion(context, isStrict) {
|
|
|
122
145
|
!parentUpdateSuccess &&
|
|
123
146
|
!parentAwaitingSiblings &&
|
|
124
147
|
devTestingStatus) {
|
|
125
|
-
//
|
|
126
|
-
const fallbackSuccess = await updateIssueStatus(task.linearId, devTestingStatus, config, localConfig.team);
|
|
148
|
+
// Parent could not be moved to testing, fallback to testing
|
|
149
|
+
const fallbackSuccess = await deps.updateIssueStatus(task.linearId, devTestingStatus, config, localConfig.team);
|
|
127
150
|
if (fallbackSuccess) {
|
|
128
|
-
console.log(`Linear: ${task.id} → ${devTestingStatus} (fallback
|
|
151
|
+
console.log(`Linear: ${task.id} → ${devTestingStatus} (fallback: parent ${task.parentIssueId} could not be moved to testing - its team is not in qa_pm_teams or the update failed; ${STRICT_FALLBACK_HINT})`);
|
|
129
152
|
return { success: true, status: devTestingStatus };
|
|
130
153
|
}
|
|
131
154
|
return {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards and post-update checks for `ttt done`
|
|
3
|
+
*/
|
|
4
|
+
import type { Task } from "../../utils.js";
|
|
5
|
+
/**
|
|
6
|
+
* Explain why a task cannot be completed from its local status, and what to
|
|
7
|
+
* do instead. `ttt done` completes in-progress work only, so the guard stays;
|
|
8
|
+
* the message names the two ways forward.
|
|
9
|
+
*/
|
|
10
|
+
export declare function describeNotInProgress(task: Pick<Task, "id" | "localStatus">): string;
|
|
11
|
+
export interface SyncUntilStatusOptions {
|
|
12
|
+
attempts?: number;
|
|
13
|
+
delayMs?: number;
|
|
14
|
+
sleep?: (ms: number) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Sync a task until the remote reports the status we just wrote. Linear reads
|
|
18
|
+
* can lag a write by a moment; syncing once would record the stale status
|
|
19
|
+
* as the local state and then report a false mismatch.
|
|
20
|
+
*/
|
|
21
|
+
export declare function syncUntilStatus(sync: () => Promise<Task | null>, expectedStatus: string | undefined, options?: SyncUntilStatusOptions): Promise<Task | null>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards and post-update checks for `ttt done`
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Explain why a task cannot be completed from its local status, and what to
|
|
6
|
+
* do instead. `ttt done` completes in-progress work only, so the guard stays;
|
|
7
|
+
* the message names the two ways forward.
|
|
8
|
+
*/
|
|
9
|
+
export function describeNotInProgress(task) {
|
|
10
|
+
const lines = [
|
|
11
|
+
`⚠️ 任務 ${task.id} 不在進行中狀態 (目前: ${task.localStatus})`,
|
|
12
|
+
];
|
|
13
|
+
if (task.localStatus === "completed" || task.localStatus === "in-review") {
|
|
14
|
+
lines.push(` 已經完成或送審;若要再次執行完成流程,請用 'ttt done ${task.id} --from-remote'。`);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
lines.push(` - 'ttt claim ${task.id}' 先領取(Linear 會多一次 In Progress 轉換),或`, ` - 'ttt done ${task.id} --from-remote' 直接從 Linear 完成(不需要先 claim)。`);
|
|
18
|
+
}
|
|
19
|
+
return lines.join("\n");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Sync a task until the remote reports the status we just wrote. Linear reads
|
|
23
|
+
* can lag a write by a moment; syncing once would record the stale status
|
|
24
|
+
* as the local state and then report a false mismatch.
|
|
25
|
+
*/
|
|
26
|
+
export async function syncUntilStatus(sync, expectedStatus, options = {}) {
|
|
27
|
+
const { attempts = 4, delayMs = 1000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), } = options;
|
|
28
|
+
let task = await sync();
|
|
29
|
+
for (let attempt = 1; task &&
|
|
30
|
+
expectedStatus &&
|
|
31
|
+
task.status !== expectedStatus &&
|
|
32
|
+
attempt < attempts; attempt++) {
|
|
33
|
+
await sleep(delayMs);
|
|
34
|
+
task = await sync();
|
|
35
|
+
}
|
|
36
|
+
return task;
|
|
37
|
+
}
|
|
@@ -7,6 +7,10 @@ export interface DoneArgs {
|
|
|
7
7
|
issueId?: string;
|
|
8
8
|
message?: string;
|
|
9
9
|
fromRemote: boolean;
|
|
10
|
+
/** Commit-ish to record instead of HEAD */
|
|
11
|
+
commit?: string;
|
|
12
|
+
/** Git repository to read the commit from */
|
|
13
|
+
repo?: string;
|
|
10
14
|
}
|
|
11
15
|
export interface CompletionContext {
|
|
12
16
|
task: Task;
|
|
@@ -5,6 +5,18 @@ export interface CommitInfo {
|
|
|
5
5
|
diffStat: string;
|
|
6
6
|
commitUrl: string | null;
|
|
7
7
|
}
|
|
8
|
-
export
|
|
8
|
+
export interface CommitLookupOptions {
|
|
9
|
+
/** Commit-ish to describe (default: HEAD of the repository). */
|
|
10
|
+
ref?: string;
|
|
11
|
+
/** Repository directory to read from (default: the process cwd). */
|
|
12
|
+
cwd?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Describe a commit. Without `ref` this is HEAD, and the diff stat is taken
|
|
16
|
+
* against the working tree like before; with `ref` the stat is that commit's
|
|
17
|
+
* own change (`<ref>~1..<ref>`), so a past commit can be recorded from any
|
|
18
|
+
* checkout. Returns null when the commit cannot be resolved.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getLatestCommit(options?: CommitLookupOptions): CommitInfo | null;
|
|
9
21
|
export declare function formatCommitLink(commit: CommitInfo): string;
|
|
10
22
|
export declare function buildCompletionComment(commit: CommitInfo, promptMessage?: string): string;
|
package/dist/scripts/lib/git.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
function
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
function git(args, cwd) {
|
|
3
|
+
return execFileSync("git", args, {
|
|
4
|
+
cwd,
|
|
5
|
+
encoding: "utf-8",
|
|
6
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
7
|
+
}).trim();
|
|
8
|
+
}
|
|
9
|
+
function getRemoteUrl(cwd) {
|
|
3
10
|
try {
|
|
4
|
-
return
|
|
5
|
-
encoding: "utf-8",
|
|
6
|
-
}).trim();
|
|
11
|
+
return git(["remote", "get-url", "origin"], cwd);
|
|
7
12
|
}
|
|
8
13
|
catch {
|
|
9
14
|
return null;
|
|
@@ -27,22 +32,31 @@ function buildCommitUrl(remoteUrl, fullHash) {
|
|
|
27
32
|
}
|
|
28
33
|
return null;
|
|
29
34
|
}
|
|
30
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Describe a commit. Without `ref` this is HEAD, and the diff stat is taken
|
|
37
|
+
* against the working tree like before; with `ref` the stat is that commit's
|
|
38
|
+
* own change (`<ref>~1..<ref>`), so a past commit can be recorded from any
|
|
39
|
+
* checkout. Returns null when the commit cannot be resolved.
|
|
40
|
+
*/
|
|
41
|
+
export function getLatestCommit(options = {}) {
|
|
42
|
+
const { ref, cwd } = options;
|
|
43
|
+
const target = ref ?? "HEAD";
|
|
31
44
|
try {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
const fullHash = git(["rev-parse", "--verify", `${target}^{commit}`], cwd);
|
|
46
|
+
const shortHash = git(["rev-parse", "--short", fullHash], cwd);
|
|
47
|
+
const message = git(["log", "-1", "--format=%s", fullHash], cwd);
|
|
48
|
+
// A root commit has no parent to diff against; keep the commit anyway.
|
|
49
|
+
let diffStat = "";
|
|
50
|
+
try {
|
|
51
|
+
diffStat = git(ref
|
|
52
|
+
? ["diff", `${fullHash}~1`, fullHash, "--stat", "--stat-width=60"]
|
|
53
|
+
: ["diff", "HEAD~1", "--stat", "--stat-width=60"], cwd);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
diffStat = "";
|
|
57
|
+
}
|
|
44
58
|
let commitUrl = null;
|
|
45
|
-
const remoteUrl = getRemoteUrl();
|
|
59
|
+
const remoteUrl = getRemoteUrl(cwd);
|
|
46
60
|
if (remoteUrl) {
|
|
47
61
|
commitUrl = buildCommitUrl(remoteUrl, fullHash);
|
|
48
62
|
}
|
package/dist/scripts/show.js
CHANGED
|
@@ -3,12 +3,12 @@ import { displayTaskFull, getStatusIcon, PRIORITY_LABELS, } from "./lib/display.
|
|
|
3
3
|
import { getStatusTransitions } from "./lib/linear.js";
|
|
4
4
|
import { getSyncStatuses } from "./lib/status-helpers.js";
|
|
5
5
|
import { getSourceType, getUserEmails, loadConfig, loadCycleData, loadLocalConfig, } from "./utils.js";
|
|
6
|
-
function taskToMarkdown(task) {
|
|
6
|
+
function taskToMarkdown(task, includeLocal = true) {
|
|
7
7
|
const lines = [];
|
|
8
8
|
const priority = PRIORITY_LABELS[task.priority] || "None";
|
|
9
9
|
lines.push(`## ${task.id}: ${task.title}`);
|
|
10
10
|
lines.push("");
|
|
11
|
-
lines.push(`- **Status**: ${task.status} (Local: ${task.localStatus})`);
|
|
11
|
+
lines.push(`- **Status**: ${task.status}${includeLocal ? ` (Local: ${task.localStatus})` : ""}`);
|
|
12
12
|
lines.push(`- **Priority**: ${priority}`);
|
|
13
13
|
lines.push(`- **Labels**: ${task.labels.length > 0 ? task.labels.join(", ") : "-"}`);
|
|
14
14
|
lines.push(`- **Assignee**: ${task.assignee || "Unassigned"}`);
|
|
@@ -380,18 +380,20 @@ Examples:
|
|
|
380
380
|
process.exit(1);
|
|
381
381
|
}
|
|
382
382
|
// Check local data for local status
|
|
383
|
+
let hasLocal = false;
|
|
383
384
|
const data = await loadCycleData();
|
|
384
385
|
if (data) {
|
|
385
386
|
const localTask = data.tasks.find((t) => t.id === issueId);
|
|
386
387
|
if (localTask) {
|
|
387
388
|
task.localStatus = localTask.localStatus;
|
|
389
|
+
hasLocal = true;
|
|
388
390
|
}
|
|
389
391
|
}
|
|
390
392
|
if (exportMarkdown) {
|
|
391
|
-
console.log(taskToMarkdown(task));
|
|
393
|
+
console.log(taskToMarkdown(task, hasLocal));
|
|
392
394
|
}
|
|
393
395
|
else {
|
|
394
|
-
displayTaskFull(task, "📋");
|
|
396
|
+
displayTaskFull(task, "📋", { status: { local: hasLocal } });
|
|
395
397
|
}
|
|
396
398
|
return;
|
|
397
399
|
}
|
|
@@ -411,7 +413,7 @@ Examples:
|
|
|
411
413
|
console.log(taskToMarkdown(task));
|
|
412
414
|
}
|
|
413
415
|
else {
|
|
414
|
-
displayTaskFull(task, "📋");
|
|
416
|
+
displayTaskFull(task, "📋", { status: { local: true } });
|
|
415
417
|
}
|
|
416
418
|
}
|
|
417
419
|
show().catch(console.error);
|
package/package.json
CHANGED
|
@@ -58,6 +58,8 @@ ttt status MP-624 --set +1 # Advance status
|
|
|
58
58
|
ttt comment MP-624 -m "msg" # Add comment to issue
|
|
59
59
|
ttt comment -m "msg" # Comment on current task
|
|
60
60
|
ttt done -m "summary" # Complete with message
|
|
61
|
+
ttt done MP-624 --commit <sha> [--repo <path>] # Close a ticket with an earlier commit
|
|
62
|
+
ttt done MP-624 --from-remote # Complete a Todo ticket without claiming it first
|
|
61
63
|
```
|
|
62
64
|
|
|
63
65
|
## Prerequisites
|