team-toon-tack 3.10.0 → 3.10.1

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.0"
16
+ "version": "3.10.1"
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.0",
4
+ "version": "3.10.1",
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.0",
3
+ "version": "3.10.1",
4
4
  "description": "透過 ttt CLI 同步與管理 Linear、Trello 任務。",
5
5
  "author": {
6
6
  "name": "wayne930242"
package/dist/bin/cli.js CHANGED
File without changes
@@ -4,9 +4,12 @@
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
+ function logParentUnchanged(parentIssueId, unfinishedChildren) {
8
+ console.log(`Linear: Parent ${parentIssueId} unchanged (unfinished sub-issues: ${unfinishedChildren.join(", ")})`);
9
+ }
7
10
  /**
8
11
  * Handle simple completion mode
9
- * Mark task as done, also mark parent as done if exists
12
+ * Mark task as done, also mark parent as done once all its sub-issues are finished
10
13
  */
11
14
  async function handleSimpleCompletion(context) {
12
15
  const { task, config, localConfig } = context;
@@ -25,10 +28,13 @@ async function handleSimpleCompletion(context) {
25
28
  }
26
29
  // Also mark parent as done if exists
27
30
  if (task.parentIssueId) {
28
- const result = await updateParentStatus(task.parentIssueId, transitions.done, localConfig.qa_pm_teams, config);
31
+ const result = await updateParentStatus(task.parentIssueId, task.id, transitions.done, localConfig.qa_pm_teams, config);
29
32
  if (result.success) {
30
33
  console.log(`Linear: Parent ${task.parentIssueId} → ${transitions.done}`);
31
34
  }
35
+ else if (result.unfinishedChildren?.length) {
36
+ logParentUnchanged(task.parentIssueId, result.unfinishedChildren);
37
+ }
32
38
  }
33
39
  return { success: true, status: transitions.done };
34
40
  }
@@ -55,10 +61,13 @@ async function handleStrictReview(context) {
55
61
  }
56
62
  // Also mark parent to testing if exists
57
63
  if (task.parentIssueId && localConfig.qa_pm_teams?.length) {
58
- const result = await updateParentToTesting(task.parentIssueId, localConfig.qa_pm_teams, config);
64
+ const result = await updateParentToTesting(task.parentIssueId, task.id, localConfig.qa_pm_teams, config, devTestingStatus);
59
65
  if (result.success) {
60
66
  console.log(`Linear: Parent ${task.parentIssueId} → ${result.testingStatus}`);
61
67
  }
68
+ else if (result.unfinishedChildren?.length) {
69
+ logParentUnchanged(task.parentIssueId, result.unfinishedChildren);
70
+ }
62
71
  }
63
72
  return { success: true, status: devTestingStatus };
64
73
  }
@@ -94,16 +103,25 @@ async function handleUpstreamCompletion(context, isStrict) {
94
103
  // Try to update parent to testing
95
104
  let parentUpdateSuccess = false;
96
105
  let parentTestingStatus;
106
+ // Parent is valid but waits for its other sub-issues; it moves when the last one completes
107
+ let parentAwaitingSiblings = false;
97
108
  if (task.parentIssueId && localConfig.qa_pm_teams?.length) {
98
- const result = await updateParentToTesting(task.parentIssueId, localConfig.qa_pm_teams, config);
109
+ const result = await updateParentToTesting(task.parentIssueId, task.id, localConfig.qa_pm_teams, config, devTestingStatus);
99
110
  parentUpdateSuccess = result.success;
100
111
  parentTestingStatus = result.testingStatus;
101
112
  if (parentUpdateSuccess) {
102
113
  console.log(`Linear: Parent ${task.parentIssueId} → ${parentTestingStatus}`);
103
114
  }
115
+ else if (result.unfinishedChildren?.length) {
116
+ parentAwaitingSiblings = true;
117
+ logParentUnchanged(task.parentIssueId, result.unfinishedChildren);
118
+ }
104
119
  }
105
120
  // Fallback logic for upstream_strict
106
- if (isStrict && !parentUpdateSuccess && devTestingStatus) {
121
+ if (isStrict &&
122
+ !parentUpdateSuccess &&
123
+ !parentAwaitingSiblings &&
124
+ devTestingStatus) {
107
125
  // No parent or parent update failed, fallback to testing
108
126
  const fallbackSuccess = await updateIssueStatus(task.linearId, devTestingStatus, config, localConfig.team);
109
127
  if (fallbackSuccess) {
@@ -1,13 +1,29 @@
1
1
  /**
2
2
  * Parent issue update logic for Linear
3
3
  */
4
+ import type { LinearClient } from "@linear/sdk";
4
5
  import type { Config, QaPmTeamConfig } from "../../utils.js";
5
6
  import type { ParentUpdateResult } from "./types.js";
7
+ export interface ChildIssueState {
8
+ identifier: string;
9
+ state?: {
10
+ name: string;
11
+ type: string;
12
+ };
13
+ }
6
14
  /**
7
- * Update parent issue to a specific status
15
+ * Sub-issues other than the completing one that are still unfinished.
16
+ * A sub-issue is finished once its state is completed/canceled, or once it
17
+ * sits in one of the hand-off statuses (e.g. Testing).
8
18
  */
9
- export declare function updateParentStatus(parentIssueId: string, targetStatus: string, _qaPmTeams: QaPmTeamConfig[] | undefined, config: Config): Promise<ParentUpdateResult>;
19
+ export declare function listUnfinishedSiblings(children: ChildIssueState[], childIssueId: string, handoffStatuses: readonly string[]): string[];
20
+ export declare function findUnfinishedSiblings(client: LinearClient, parentId: string, childIssueId: string, handoffStatuses: readonly string[]): Promise<string[]>;
10
21
  /**
11
- * Update parent issue to testing status (uses QA team config)
22
+ * Update parent issue to a specific status once all its sub-issues are finished
12
23
  */
13
- export declare function updateParentToTesting(parentIssueId: string, qaPmTeams: QaPmTeamConfig[], config: Config): Promise<ParentUpdateResult>;
24
+ export declare function updateParentStatus(parentIssueId: string, childIssueId: string, targetStatus: string, _qaPmTeams: QaPmTeamConfig[] | undefined, config: Config): Promise<ParentUpdateResult>;
25
+ /**
26
+ * Update parent issue to testing status (uses QA team config) once all its
27
+ * sub-issues are finished or handed off to testing
28
+ */
29
+ export declare function updateParentToTesting(parentIssueId: string, childIssueId: string, qaPmTeams: QaPmTeamConfig[], config: Config, devTestingStatus: string | undefined): Promise<ParentUpdateResult>;
@@ -3,10 +3,38 @@
3
3
  */
4
4
  import { getLinearClient } from "../../utils.js";
5
5
  import { getWorkflowStates } from "../linear.js";
6
+ const FINISHED_STATE_TYPES = new Set(["completed", "canceled"]);
6
7
  /**
7
- * Update parent issue to a specific status
8
+ * Sub-issues other than the completing one that are still unfinished.
9
+ * A sub-issue is finished once its state is completed/canceled, or once it
10
+ * sits in one of the hand-off statuses (e.g. Testing).
8
11
  */
9
- export async function updateParentStatus(parentIssueId, targetStatus, _qaPmTeams, config) {
12
+ export function listUnfinishedSiblings(children, childIssueId, handoffStatuses) {
13
+ return children
14
+ .filter((child) => child.identifier !== childIssueId)
15
+ .filter((child) => !child.state ||
16
+ !(FINISHED_STATE_TYPES.has(child.state.type) ||
17
+ handoffStatuses.includes(child.state.name)))
18
+ .map((child) => child.identifier);
19
+ }
20
+ export async function findUnfinishedSiblings(client, parentId, childIssueId, handoffStatuses) {
21
+ const children = await client.issues({
22
+ filter: { parent: { id: { eq: parentId } } },
23
+ first: 250,
24
+ });
25
+ const childStates = await Promise.all(children.nodes.map(async (child) => {
26
+ const state = await child.state;
27
+ return {
28
+ identifier: child.identifier,
29
+ state: state ? { name: state.name, type: state.type } : undefined,
30
+ };
31
+ }));
32
+ return listUnfinishedSiblings(childStates, childIssueId, handoffStatuses);
33
+ }
34
+ /**
35
+ * Update parent issue to a specific status once all its sub-issues are finished
36
+ */
37
+ export async function updateParentStatus(parentIssueId, childIssueId, targetStatus, _qaPmTeams, config) {
10
38
  try {
11
39
  const client = getLinearClient();
12
40
  const searchResult = await client.searchIssues(parentIssueId);
@@ -31,6 +59,10 @@ export async function updateParentStatus(parentIssueId, targetStatus, _qaPmTeams
31
59
  if (!targetState) {
32
60
  return { success: false };
33
61
  }
62
+ const unfinishedChildren = await findUnfinishedSiblings(client, parentIssue.id, childIssueId, []);
63
+ if (unfinishedChildren.length > 0) {
64
+ return { success: false, unfinishedChildren };
65
+ }
34
66
  // Update the parent issue
35
67
  await client.updateIssue(parentIssue.id, {
36
68
  stateId: targetState.id,
@@ -43,9 +75,10 @@ export async function updateParentStatus(parentIssueId, targetStatus, _qaPmTeams
43
75
  }
44
76
  }
45
77
  /**
46
- * Update parent issue to testing status (uses QA team config)
78
+ * Update parent issue to testing status (uses QA team config) once all its
79
+ * sub-issues are finished or handed off to testing
47
80
  */
48
- export async function updateParentToTesting(parentIssueId, qaPmTeams, config) {
81
+ export async function updateParentToTesting(parentIssueId, childIssueId, qaPmTeams, config, devTestingStatus) {
49
82
  try {
50
83
  const client = getLinearClient();
51
84
  const searchResult = await client.searchIssues(parentIssueId);
@@ -76,6 +109,14 @@ export async function updateParentToTesting(parentIssueId, qaPmTeams, config) {
76
109
  if (!testingState) {
77
110
  return { success: false };
78
111
  }
112
+ const handoffStatuses = qaPmTeams.map((qp) => qp.testing_status);
113
+ if (devTestingStatus) {
114
+ handoffStatuses.push(devTestingStatus);
115
+ }
116
+ const unfinishedChildren = await findUnfinishedSiblings(client, parentIssue.id, childIssueId, handoffStatuses);
117
+ if (unfinishedChildren.length > 0) {
118
+ return { success: false, unfinishedChildren };
119
+ }
79
120
  // Update the parent issue
80
121
  await client.updateIssue(parentIssue.id, {
81
122
  stateId: testingState.id,
@@ -24,4 +24,6 @@ export interface ParentUpdateResult {
24
24
  success: boolean;
25
25
  status?: string;
26
26
  testingStatus?: string;
27
+ /** Other sub-issues still open; the parent was left unchanged */
28
+ unfinishedChildren?: string[];
27
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "team-toon-tack",
3
- "version": "3.10.0",
3
+ "version": "3.10.1",
4
4
  "description": "Linear & Trello task sync & management CLI with TOON format",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +0,0 @@
1
- export {};
@@ -1,61 +0,0 @@
1
- import { displayTaskFull } from "./lib/display.js";
2
- import { fetchIssueDetail } from "./lib/sync.js";
3
- import { loadCycleData } from "./utils.js";
4
- async function getIssue() {
5
- const args = process.argv.slice(2);
6
- if (args.includes("--help") || args.includes("-h")) {
7
- console.log(`Usage: ttt get-issue <issue-id> [--local]
8
-
9
- Fetch and display issue details from Linear.
10
-
11
- Arguments:
12
- issue-id Issue ID (e.g., MP-624). Required.
13
-
14
- Options:
15
- --local Only show from local cycle data, don't fetch from Linear
16
-
17
- Examples:
18
- ttt get-issue MP-624 # Fetch from Linear and display
19
- ttt get-issue MP-624 --local # Show from local data only`);
20
- process.exit(0);
21
- }
22
- const localOnly = args.includes("--local");
23
- const issueId = args.find((arg) => !arg.startsWith("-"));
24
- if (!issueId) {
25
- console.error("Issue ID is required.");
26
- console.error("Usage: ttt get-issue <issue-id>");
27
- process.exit(1);
28
- }
29
- // If local only, get from cycle data
30
- if (localOnly) {
31
- const data = await loadCycleData();
32
- if (!data) {
33
- console.error("No cycle data found. Run ttt sync first.");
34
- process.exit(1);
35
- }
36
- const task = data.tasks.find((t) => t.id === issueId || t.id === `MP-${issueId}`);
37
- if (!task) {
38
- console.error(`Issue ${issueId} not found in local data.`);
39
- process.exit(1);
40
- }
41
- displayTaskFull(task, "📋");
42
- return;
43
- }
44
- // Fetch from Linear
45
- console.log(`Fetching ${issueId} from Linear...`);
46
- const task = await fetchIssueDetail(issueId);
47
- if (!task) {
48
- console.error(`Issue ${issueId} not found in Linear.`);
49
- process.exit(1);
50
- }
51
- // Check local data for local status
52
- const data = await loadCycleData();
53
- if (data) {
54
- const localTask = data.tasks.find((t) => t.id === issueId);
55
- if (localTask) {
56
- task.localStatus = localTask.localStatus;
57
- }
58
- }
59
- displayTaskFull(task, "📋");
60
- }
61
- getIssue().catch(console.error);
@@ -1,9 +0,0 @@
1
- export declare function isLinearImageUrl(url: string): boolean;
2
- /**
3
- * Extract Linear image URLs from markdown text (description, comments)
4
- */
5
- export declare function extractLinearImageUrls(text: string): string[];
6
- export declare function downloadLinearFile(url: string, issueId: string, attachmentId: string, outputDir: string): Promise<string | undefined>;
7
- export declare const downloadLinearImage: typeof downloadLinearFile;
8
- export declare function clearIssueImages(outputDir: string, issueId: string): Promise<void>;
9
- export declare function ensureOutputDir(outputDir: string): Promise<void>;
@@ -1,136 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- const LINEAR_IMAGE_DOMAINS = [
4
- "uploads.linear.app",
5
- "linear-uploads.s3.us-west-2.amazonaws.com",
6
- ];
7
- export function isLinearImageUrl(url) {
8
- try {
9
- const parsed = new URL(url);
10
- return LINEAR_IMAGE_DOMAINS.some((domain) => parsed.host.includes(domain));
11
- }
12
- catch {
13
- return false;
14
- }
15
- }
16
- /**
17
- * Extract Linear image URLs from markdown text (description, comments)
18
- */
19
- export function extractLinearImageUrls(text) {
20
- const urls = [];
21
- // Match markdown image syntax ![alt](url) and plain URLs
22
- const patterns = [
23
- /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/g, // ![alt](url)
24
- /(https?:\/\/uploads\.linear\.app\/[^\s)>\]]+)/g, // Plain Linear upload URLs
25
- ];
26
- for (const pattern of patterns) {
27
- let match = pattern.exec(text);
28
- while (match) {
29
- const url = match[1];
30
- if (isLinearImageUrl(url) && !urls.includes(url)) {
31
- urls.push(url);
32
- }
33
- match = pattern.exec(text);
34
- }
35
- }
36
- return urls;
37
- }
38
- // MIME type to extension mapping
39
- const MIME_TO_EXT = {
40
- // Images
41
- "image/jpeg": "jpg",
42
- "image/png": "png",
43
- "image/gif": "gif",
44
- "image/webp": "webp",
45
- "image/svg+xml": "svg",
46
- // Videos
47
- "video/mp4": "mp4",
48
- "video/webm": "webm",
49
- "video/quicktime": "mov",
50
- "video/x-msvideo": "avi",
51
- // Documents
52
- "application/json": "json",
53
- "application/pdf": "pdf",
54
- "text/plain": "txt",
55
- "text/html": "html",
56
- "text/css": "css",
57
- "text/javascript": "js",
58
- "application/javascript": "js",
59
- // Archives
60
- "application/zip": "zip",
61
- "application/gzip": "gz",
62
- "application/x-tar": "tar",
63
- };
64
- function getFileExtension(url, contentType) {
65
- // Try to get extension from content-type header
66
- if (contentType) {
67
- // Extract base MIME type (ignore charset and other params)
68
- const baseMime = contentType.split(";")[0].trim().toLowerCase();
69
- // Check our mapping
70
- const mappedExt = MIME_TO_EXT[baseMime];
71
- if (mappedExt) {
72
- const isImage = baseMime.startsWith("image/");
73
- return { ext: mappedExt, isImage };
74
- }
75
- // Fallback: extract from MIME type pattern
76
- const match = baseMime.match(/^(\w+)\/(\w+)/);
77
- if (match) {
78
- const [, type, subtype] = match;
79
- const isImage = type === "image";
80
- return { ext: subtype, isImage };
81
- }
82
- }
83
- // Fallback: try to get from URL path
84
- const urlPath = new URL(url).pathname;
85
- const ext = path.extname(urlPath).slice(1).toLowerCase();
86
- if (ext) {
87
- const isImage = ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext);
88
- return { ext: ext === "jpeg" ? "jpg" : ext, isImage };
89
- }
90
- // Last resort: unknown binary
91
- return { ext: "bin", isImage: false };
92
- }
93
- export async function downloadLinearFile(url, issueId, attachmentId, outputDir) {
94
- try {
95
- // Linear files require authentication
96
- const headers = {};
97
- if (process.env.LINEAR_API_KEY) {
98
- headers.Authorization = process.env.LINEAR_API_KEY;
99
- }
100
- const response = await fetch(url, { headers });
101
- if (!response.ok) {
102
- console.error(`Failed to download file: ${response.status}`);
103
- return undefined;
104
- }
105
- const contentType = response.headers.get("content-type") || undefined;
106
- const { ext } = getFileExtension(url, contentType);
107
- const filename = `${issueId}_${attachmentId}.${ext}`;
108
- const filepath = path.join(outputDir, filename);
109
- const buffer = await response.arrayBuffer();
110
- await fs.writeFile(filepath, Buffer.from(buffer));
111
- return filepath;
112
- }
113
- catch (error) {
114
- console.error(`Error downloading file: ${error}`);
115
- return undefined;
116
- }
117
- }
118
- // Alias for backwards compatibility
119
- export const downloadLinearImage = downloadLinearFile;
120
- export async function clearIssueImages(outputDir, issueId) {
121
- try {
122
- const files = await fs.readdir(outputDir);
123
- const issuePrefix = `${issueId}_`;
124
- for (const file of files) {
125
- if (file.startsWith(issuePrefix)) {
126
- await fs.unlink(path.join(outputDir, file));
127
- }
128
- }
129
- }
130
- catch {
131
- // Directory doesn't exist or other error, ignore
132
- }
133
- }
134
- export async function ensureOutputDir(outputDir) {
135
- await fs.mkdir(outputDir, { recursive: true });
136
- }