team-toon-tack 3.10.0 → 3.10.2
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/dist/bin/cli.js +37 -9
- package/dist/scripts/init.js +3 -1
- package/dist/scripts/lib/done/linear-handler.js +23 -5
- package/dist/scripts/lib/done/parent-issue.d.ts +20 -4
- package/dist/scripts/lib/done/parent-issue.js +45 -4
- package/dist/scripts/lib/done/types.d.ts +2 -0
- package/dist/scripts/utils.d.ts +27 -1
- package/dist/scripts/utils.js +63 -10
- package/package.json +1 -1
- package/dist/scripts/get-issue.d.ts +0 -1
- package/dist/scripts/get-issue.js +0 -61
- package/dist/scripts/lib/images.d.ts +0 -9
- package/dist/scripts/lib/images.js +0 -136
package/dist/bin/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import { loadDotEnv, resolveLinearApiKey } from "../scripts/lib/env.js";
|
|
6
|
+
import { findAncestorWithTtt } from "../scripts/utils.js";
|
|
6
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
8
|
// When running from dist/bin/cli.js, we need to go up two levels to find package.json
|
|
8
9
|
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"));
|
|
@@ -51,7 +52,8 @@ COMMANDS:
|
|
|
51
52
|
version Show version
|
|
52
53
|
|
|
53
54
|
GLOBAL OPTIONS:
|
|
54
|
-
--dir <path> Config directory (default: .ttt
|
|
55
|
+
--dir <path> Config directory (default: nearest ancestor .ttt,
|
|
56
|
+
searched upward from cwd, stopping at $HOME)
|
|
55
57
|
Can also set via TOON_DIR environment variable
|
|
56
58
|
-d <path> Shortcut for --dir; ignored for create/edit/comment
|
|
57
59
|
(where -d means --description)
|
|
@@ -88,19 +90,39 @@ const SHORT_D_RESERVED_FOR_SUBCOMMAND = new Set([
|
|
|
88
90
|
"comment",
|
|
89
91
|
]);
|
|
90
92
|
function parseGlobalArgs(command, args) {
|
|
91
|
-
let
|
|
93
|
+
let explicitDir;
|
|
92
94
|
const commandArgs = [];
|
|
93
95
|
const allowShortD = !SHORT_D_RESERVED_FOR_SUBCOMMAND.has(command);
|
|
94
96
|
for (let i = 0; i < args.length; i++) {
|
|
95
97
|
const arg = args[i];
|
|
96
98
|
if (arg === "--dir" || (allowShortD && arg === "-d")) {
|
|
97
|
-
|
|
99
|
+
explicitDir = resolve(args[++i] || ".");
|
|
98
100
|
}
|
|
99
101
|
else {
|
|
100
102
|
commandArgs.push(arg);
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
|
-
|
|
105
|
+
// --dir wins over everything, then TOON_DIR/LINEAR_TOON_DIR, then an
|
|
106
|
+
// upward search from cwd for the nearest ancestor holding `.ttt`.
|
|
107
|
+
if (explicitDir)
|
|
108
|
+
return { dir: explicitDir, commandArgs };
|
|
109
|
+
if (process.env.TOON_DIR) {
|
|
110
|
+
return { dir: resolve(process.env.TOON_DIR), commandArgs };
|
|
111
|
+
}
|
|
112
|
+
if (process.env.LINEAR_TOON_DIR) {
|
|
113
|
+
return { dir: resolve(process.env.LINEAR_TOON_DIR), commandArgs };
|
|
114
|
+
}
|
|
115
|
+
// `init` always targets cwd/.ttt: reusing a discovered ancestor would let
|
|
116
|
+
// it silently rewrite a shared monorepo-root config from inside a
|
|
117
|
+
// worktree instead of creating (or reporting) one where the user stands.
|
|
118
|
+
if (command === "init") {
|
|
119
|
+
return { dir: resolve(process.cwd(), ".ttt"), commandArgs };
|
|
120
|
+
}
|
|
121
|
+
const found = findAncestorWithTtt(process.cwd());
|
|
122
|
+
return {
|
|
123
|
+
dir: found.dir !== null ? join(found.dir, ".ttt") : null,
|
|
124
|
+
commandArgs,
|
|
125
|
+
};
|
|
104
126
|
}
|
|
105
127
|
async function main() {
|
|
106
128
|
const args = process.argv.slice(2);
|
|
@@ -118,17 +140,23 @@ async function main() {
|
|
|
118
140
|
const command = args[0];
|
|
119
141
|
const restArgs = args.slice(1);
|
|
120
142
|
const { dir, commandArgs } = parseGlobalArgs(command, restArgs);
|
|
121
|
-
// Set TOON_DIR for scripts to use
|
|
122
|
-
|
|
143
|
+
// Set TOON_DIR for scripts to use. When no explicit override applies and
|
|
144
|
+
// the upward search found nothing, leave it unset so the invoked script's
|
|
145
|
+
// own resolution reports the exact range it searched.
|
|
146
|
+
if (dir !== null) {
|
|
147
|
+
process.env.TOON_DIR = dir;
|
|
148
|
+
}
|
|
123
149
|
// Load .ttt/.env (if present) and resolve configured Linear API key env
|
|
124
150
|
// var into LINEAR_API_KEY so downstream code is workspace-aware.
|
|
125
151
|
// Skip the resolver for `init` so the workspace picker sees the raw
|
|
126
152
|
// env — otherwise we'd mirror the previously-saved key over LINEAR_API_KEY
|
|
127
153
|
// and the user's shell-level key would appear to point to the saved
|
|
128
154
|
// workspace.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
155
|
+
if (dir !== null) {
|
|
156
|
+
await loadDotEnv(join(dir, ".env"));
|
|
157
|
+
if (command !== "init") {
|
|
158
|
+
await resolveLinearApiKey(join(dir, "local.toon"));
|
|
159
|
+
}
|
|
132
160
|
}
|
|
133
161
|
if (!COMMANDS.includes(command)) {
|
|
134
162
|
console.error(`Unknown command: ${command}`);
|
package/dist/scripts/init.js
CHANGED
|
@@ -14,7 +14,9 @@ async function init() {
|
|
|
14
14
|
process.exit(0);
|
|
15
15
|
}
|
|
16
16
|
const options = parseArgs(args);
|
|
17
|
-
|
|
17
|
+
// `init` always targets cwd/.ttt, never a discovered ancestor - see
|
|
18
|
+
// getPaths' doc comment.
|
|
19
|
+
const paths = getPaths({ search: false });
|
|
18
20
|
// Convert paths to InitPaths format
|
|
19
21
|
const initPaths = {
|
|
20
22
|
baseDir: paths.baseDir,
|
|
@@ -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
|
|
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 &&
|
|
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
|
-
*
|
|
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
|
|
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
|
|
22
|
+
* Update parent issue to a specific status once all its sub-issues are finished
|
|
12
23
|
*/
|
|
13
|
-
export declare function
|
|
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
|
-
*
|
|
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
|
|
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,
|
package/dist/scripts/utils.d.ts
CHANGED
|
@@ -1,17 +1,43 @@
|
|
|
1
1
|
import { LinearClient } from "@linear/sdk";
|
|
2
2
|
import type { SourceIssue } from "./lib/adapters/types.js";
|
|
3
|
+
export interface AncestorSearch {
|
|
4
|
+
/** Directory the search started from (the process cwd). */
|
|
5
|
+
from: string;
|
|
6
|
+
/** Last directory checked before the search gave up ($HOME or the filesystem root). */
|
|
7
|
+
to: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Walk up from `startDir` looking for a `.ttt` directory, stopping at $HOME
|
|
11
|
+
* or the filesystem root, whichever comes first, so the search never escapes
|
|
12
|
+
* the user's home tree. Returns the directory that holds `.ttt` (not the
|
|
13
|
+
* `.ttt` path itself), or `null` with the searched range when none is found.
|
|
14
|
+
*/
|
|
15
|
+
export declare function findAncestorWithTtt(startDir: string): {
|
|
16
|
+
dir: string;
|
|
17
|
+
} | {
|
|
18
|
+
dir: null;
|
|
19
|
+
search: AncestorSearch;
|
|
20
|
+
};
|
|
3
21
|
/**
|
|
4
22
|
* Resolved on every call, so a TOON_DIR set after this module loads still
|
|
5
23
|
* applies. Caching it at module load made the base directory depend on import
|
|
6
24
|
* order.
|
|
25
|
+
*
|
|
26
|
+
* Pass `{ search: false }` to opt out of the upward ancestor search (used by
|
|
27
|
+
* `init`, which must always target cwd/.ttt regardless of what a parent
|
|
28
|
+
* directory holds).
|
|
7
29
|
*/
|
|
8
|
-
export declare function getPaths(
|
|
30
|
+
export declare function getPaths(opts?: {
|
|
31
|
+
search?: boolean;
|
|
32
|
+
}): {
|
|
9
33
|
baseDir: string;
|
|
10
34
|
configPath: string;
|
|
11
35
|
cyclePath: string;
|
|
12
36
|
localPath: string;
|
|
13
37
|
outputPath: string;
|
|
14
38
|
envPath: string;
|
|
39
|
+
/** Set only when no ancestor `.ttt` was found, for error messages. */
|
|
40
|
+
search: AncestorSearch | null;
|
|
15
41
|
};
|
|
16
42
|
export interface TeamConfig {
|
|
17
43
|
id: string;
|
package/dist/scripts/utils.js
CHANGED
|
@@ -1,29 +1,73 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { LinearClient } from "@linear/sdk";
|
|
4
6
|
// decode uses { strict: false } because encode() produces inline arrays
|
|
5
7
|
// that strict mode rejects (RangeError: Expected 0 inline array items).
|
|
6
8
|
import { decode, encode } from "@toon-format/toon";
|
|
9
|
+
/**
|
|
10
|
+
* Walk up from `startDir` looking for a `.ttt` directory, stopping at $HOME
|
|
11
|
+
* or the filesystem root, whichever comes first, so the search never escapes
|
|
12
|
+
* the user's home tree. Returns the directory that holds `.ttt` (not the
|
|
13
|
+
* `.ttt` path itself), or `null` with the searched range when none is found.
|
|
14
|
+
*/
|
|
15
|
+
export function findAncestorWithTtt(startDir) {
|
|
16
|
+
const home = process.env.HOME || os.homedir();
|
|
17
|
+
let dir = startDir;
|
|
18
|
+
for (;;) {
|
|
19
|
+
if (existsSync(path.join(dir, ".ttt"))) {
|
|
20
|
+
return { dir };
|
|
21
|
+
}
|
|
22
|
+
if (dir === home)
|
|
23
|
+
break;
|
|
24
|
+
const parent = path.dirname(dir);
|
|
25
|
+
if (parent === dir)
|
|
26
|
+
break; // filesystem root
|
|
27
|
+
dir = parent;
|
|
28
|
+
}
|
|
29
|
+
return { dir: null, search: { from: startDir, to: dir } };
|
|
30
|
+
}
|
|
7
31
|
// Resolve base directory - supports multiple configuration methods
|
|
8
|
-
function
|
|
9
|
-
// 1. Check for TOON_DIR environment variable (set by CLI or user)
|
|
32
|
+
function resolveBaseDir(searchAncestors) {
|
|
33
|
+
// 1. Check for TOON_DIR environment variable (set by CLI or user) - wins
|
|
34
|
+
// over the upward search, same as an explicit --dir.
|
|
10
35
|
if (process.env.TOON_DIR) {
|
|
11
|
-
return path.resolve(process.env.TOON_DIR);
|
|
36
|
+
return { baseDir: path.resolve(process.env.TOON_DIR), search: null };
|
|
12
37
|
}
|
|
13
38
|
// 2. Check for legacy LINEAR_TOON_DIR environment variable
|
|
14
39
|
if (process.env.LINEAR_TOON_DIR) {
|
|
15
|
-
return path.resolve(process.env.LINEAR_TOON_DIR);
|
|
40
|
+
return { baseDir: path.resolve(process.env.LINEAR_TOON_DIR), search: null };
|
|
16
41
|
}
|
|
17
|
-
|
|
18
|
-
|
|
42
|
+
const cwd = process.cwd();
|
|
43
|
+
// 3. `init` (and anything else that opts out) always targets cwd/.ttt:
|
|
44
|
+
// reusing a discovered ancestor would let it silently rewrite a shared
|
|
45
|
+
// monorepo-root config from inside a worktree.
|
|
46
|
+
if (!searchAncestors) {
|
|
47
|
+
return { baseDir: path.join(cwd, ".ttt"), search: null };
|
|
48
|
+
}
|
|
49
|
+
// 4. Walk up from cwd to the nearest ancestor holding `.ttt` (monorepo
|
|
50
|
+
// root, typically), so commands work from nested submodules/worktrees.
|
|
51
|
+
const found = findAncestorWithTtt(cwd);
|
|
52
|
+
if (found.dir !== null) {
|
|
53
|
+
return { baseDir: path.join(found.dir, ".ttt"), search: null };
|
|
54
|
+
}
|
|
55
|
+
// 5. Nothing found anywhere up the chain - fall back to cwd/.ttt so the
|
|
56
|
+
// caller's error message can report the exact directory it tried, plus
|
|
57
|
+
// the range it searched.
|
|
58
|
+
return { baseDir: path.join(cwd, ".ttt"), search: found.search };
|
|
19
59
|
}
|
|
20
60
|
/**
|
|
21
61
|
* Resolved on every call, so a TOON_DIR set after this module loads still
|
|
22
62
|
* applies. Caching it at module load made the base directory depend on import
|
|
23
63
|
* order.
|
|
64
|
+
*
|
|
65
|
+
* Pass `{ search: false }` to opt out of the upward ancestor search (used by
|
|
66
|
+
* `init`, which must always target cwd/.ttt regardless of what a parent
|
|
67
|
+
* directory holds).
|
|
24
68
|
*/
|
|
25
|
-
export function getPaths() {
|
|
26
|
-
const baseDir =
|
|
69
|
+
export function getPaths(opts = {}) {
|
|
70
|
+
const { baseDir, search } = resolveBaseDir(opts.search ?? true);
|
|
27
71
|
return {
|
|
28
72
|
baseDir,
|
|
29
73
|
configPath: path.join(baseDir, "config.toon"),
|
|
@@ -31,6 +75,8 @@ export function getPaths() {
|
|
|
31
75
|
localPath: path.join(baseDir, "local.toon"),
|
|
32
76
|
outputPath: path.join(baseDir, "output"),
|
|
33
77
|
envPath: path.join(baseDir, ".env"),
|
|
78
|
+
/** Set only when no ancestor `.ttt` was found, for error messages. */
|
|
79
|
+
search,
|
|
34
80
|
};
|
|
35
81
|
}
|
|
36
82
|
// Linear priority value to name mapping (fixed by Linear API)
|
|
@@ -63,26 +109,33 @@ export async function fileExists(filePath) {
|
|
|
63
109
|
return false;
|
|
64
110
|
}
|
|
65
111
|
}
|
|
112
|
+
function reportSearchRange(search) {
|
|
113
|
+
if (search) {
|
|
114
|
+
console.error(`No .ttt directory found from ${search.from} up to ${search.to}.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
66
117
|
export async function loadConfig() {
|
|
67
|
-
const { configPath } = getPaths();
|
|
118
|
+
const { configPath, search } = getPaths();
|
|
68
119
|
try {
|
|
69
120
|
const fileContent = await fs.readFile(configPath, "utf-8");
|
|
70
121
|
return decode(fileContent, { strict: false });
|
|
71
122
|
}
|
|
72
123
|
catch (error) {
|
|
73
124
|
console.error(`Error loading config from ${configPath}:`, error);
|
|
125
|
+
reportSearchRange(search);
|
|
74
126
|
console.error("Run `bun run init` to create configuration files.");
|
|
75
127
|
process.exit(1);
|
|
76
128
|
}
|
|
77
129
|
}
|
|
78
130
|
export async function loadLocalConfig() {
|
|
79
|
-
const { localPath } = getPaths();
|
|
131
|
+
const { localPath, search } = getPaths();
|
|
80
132
|
try {
|
|
81
133
|
const fileContent = await fs.readFile(localPath, "utf-8");
|
|
82
134
|
return decode(fileContent, { strict: false });
|
|
83
135
|
}
|
|
84
136
|
catch {
|
|
85
137
|
console.error(`Error: ${localPath} not found.`);
|
|
138
|
+
reportSearchRange(search);
|
|
86
139
|
console.error("Run `bun run init` to create local configuration.");
|
|
87
140
|
process.exit(1);
|
|
88
141
|
}
|
package/package.json
CHANGED
|
@@ -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  and plain URLs
|
|
22
|
-
const patterns = [
|
|
23
|
-
/!\[[^\]]*\]\((https?:\/\/[^)]+)\)/g, // 
|
|
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
|
-
}
|