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.
@@ -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.10.3"
16
+ "version": "3.11.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.10.3",
4
+ "version": "3.11.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.10.3",
3
+ "version": "3.11.0",
4
4
  "description": "透過 ttt CLI 同步與管理 Linear、Trello 任務。",
5
5
  "author": {
6
6
  "name": "wayne930242"
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 狀態。設定 QA 團隊時的預設值。 |
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 存在於遠端但未同步到本地資料時,使用 `--from-remote`(或 `-r`)。
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
 
@@ -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
@@ -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 |
@@ -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>;
@@ -95,20 +95,50 @@ async function resolveStatusId(config, adapter, teamId, statusName) {
95
95
  }
96
96
  return target.id;
97
97
  }
98
- function resolveLabelIds(config, labelArg) {
99
- if (!labelArg || !config.labels)
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.split(",").map((n) => n.trim());
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
- console.error(`Warning: label "${name}" not found in config, skipping.`);
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 = {
@@ -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
- if (inProgressTasks.length === 0) {
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.log(`⚠️ 任務 ${localTask.id} 不在進行中狀態 (目前: ${localTask.localStatus})`);
93
+ console.error(describeNotInProgress(localTask));
91
94
  process.exit(1);
92
95
  }
93
96
  task = localTask;
94
97
  }
95
- // Get latest commit
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
- const syncedTask = await syncSingleIssue(task.id, {
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, // Let remote status determine local status
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(console.error);
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: { team: { id: { eq: teamId } } },
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 declare function displayTaskInfo(task: Task): void;
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 displayTaskInfo(task) {
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 && localConfig.qa_pm_teams?.length) {
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
- // No parent or parent update failed, fallback to testing
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, no valid parent)`);
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 declare function getLatestCommit(): CommitInfo | null;
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;
@@ -1,9 +1,14 @@
1
- import { execSync } from "node:child_process";
2
- function getRemoteUrl() {
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 execSync("git remote get-url origin", {
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
- export function getLatestCommit() {
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 shortHash = execSync("git rev-parse --short HEAD", {
33
- encoding: "utf-8",
34
- }).trim();
35
- const fullHash = execSync("git rev-parse HEAD", {
36
- encoding: "utf-8",
37
- }).trim();
38
- const message = execSync("git log -1 --format=%s", {
39
- encoding: "utf-8",
40
- }).trim();
41
- const diffStat = execSync("git diff HEAD~1 --stat --stat-width=60", {
42
- encoding: "utf-8",
43
- }).trim();
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "team-toon-tack",
3
- "version": "3.10.3",
3
+ "version": "3.11.0",
4
4
  "description": "Linear & Trello task sync & management CLI with TOON format",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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