team-toon-tack 1.7.0 ā 2.0.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/LICENSE +21 -0
- package/README.md +44 -145
- package/README.zh-TW.md +65 -451
- package/dist/scripts/done-job.js +23 -3
- package/dist/scripts/init.js +346 -15
- package/dist/scripts/lib/config-builder.d.ts +1 -1
- package/dist/scripts/lib/config-builder.js +5 -1
- package/dist/scripts/lib/display.js +14 -3
- package/dist/scripts/lib/images.d.ts +9 -0
- package/dist/scripts/lib/images.js +107 -0
- package/dist/scripts/lib/linear.d.ts +1 -1
- package/dist/scripts/lib/linear.js +2 -0
- package/dist/scripts/status.js +15 -15
- package/dist/scripts/sync.js +100 -9
- package/dist/scripts/utils.d.ts +6 -1
- package/dist/scripts/utils.js +2 -0
- package/dist/scripts/work-on.js +8 -2
- package/package.json +1 -1
- package/templates/claude-code-commands/work-on.md +12 -2
package/dist/scripts/init.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { decode, encode } from "@toon-format/toon";
|
|
4
5
|
import prompts from "prompts";
|
|
5
6
|
import { buildConfig, buildLocalConfig, findTeamKey, findUserKey, getDefaultStatusTransitions, } from "./lib/config-builder.js";
|
|
@@ -116,6 +117,40 @@ async function selectTeams(teams, options) {
|
|
|
116
117
|
}
|
|
117
118
|
return { selected: selectedTeams, primary: primaryTeam };
|
|
118
119
|
}
|
|
120
|
+
async function selectQaPmTeam(teams, primaryTeam, options) {
|
|
121
|
+
// Only ask if there are multiple teams and interactive mode
|
|
122
|
+
if (!options.interactive || teams.length <= 1) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
// Filter out primary team from choices
|
|
126
|
+
const otherTeams = teams.filter((t) => t.id !== primaryTeam.id);
|
|
127
|
+
if (otherTeams.length === 0) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
console.log("\nš QA/PM Team Configuration:");
|
|
131
|
+
const response = await prompts({
|
|
132
|
+
type: "select",
|
|
133
|
+
name: "qaPmTeamId",
|
|
134
|
+
message: "Select QA/PM team (for cross-team parent issue updates):",
|
|
135
|
+
choices: [
|
|
136
|
+
{
|
|
137
|
+
title: "(None - skip)",
|
|
138
|
+
value: undefined,
|
|
139
|
+
description: "No cross-team parent updates",
|
|
140
|
+
},
|
|
141
|
+
...otherTeams.map((t) => ({
|
|
142
|
+
title: t.name,
|
|
143
|
+
value: t.id,
|
|
144
|
+
description: "Parent issues in this team will be updated to Testing",
|
|
145
|
+
})),
|
|
146
|
+
],
|
|
147
|
+
initial: 0,
|
|
148
|
+
});
|
|
149
|
+
if (!response.qaPmTeamId) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
return teams.find((t) => t.id === response.qaPmTeamId);
|
|
153
|
+
}
|
|
119
154
|
async function selectUser(users, options) {
|
|
120
155
|
let currentUser = users[0];
|
|
121
156
|
if (options.user) {
|
|
@@ -157,6 +192,31 @@ async function selectLabelFilter(labels, options) {
|
|
|
157
192
|
}
|
|
158
193
|
return undefined;
|
|
159
194
|
}
|
|
195
|
+
async function selectStatusSource(options) {
|
|
196
|
+
if (!options.interactive) {
|
|
197
|
+
return "remote"; // default
|
|
198
|
+
}
|
|
199
|
+
console.log("\nš Configure status sync mode:");
|
|
200
|
+
const response = await prompts({
|
|
201
|
+
type: "select",
|
|
202
|
+
name: "statusSource",
|
|
203
|
+
message: "Where should status updates be stored?",
|
|
204
|
+
choices: [
|
|
205
|
+
{
|
|
206
|
+
title: "Remote (recommended)",
|
|
207
|
+
value: "remote",
|
|
208
|
+
description: "Update Linear immediately when you work-on or complete tasks",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
title: "Local",
|
|
212
|
+
value: "local",
|
|
213
|
+
description: "Work offline, then sync to Linear with 'sync --update'",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
initial: 0,
|
|
217
|
+
});
|
|
218
|
+
return response.statusSource || "remote";
|
|
219
|
+
}
|
|
160
220
|
async function selectStatusMappings(states, options) {
|
|
161
221
|
const defaults = getDefaultStatusTransitions(states);
|
|
162
222
|
if (!options.interactive || states.length === 0) {
|
|
@@ -201,13 +261,215 @@ async function selectStatusMappings(states, options) {
|
|
|
201
261
|
? testingChoices.findIndex((c) => c.value === defaults.testing)
|
|
202
262
|
: 0,
|
|
203
263
|
});
|
|
264
|
+
const blockedChoices = [
|
|
265
|
+
{ title: "(None)", value: undefined },
|
|
266
|
+
...stateChoices,
|
|
267
|
+
];
|
|
268
|
+
const blockedResponse = await prompts({
|
|
269
|
+
type: "select",
|
|
270
|
+
name: "blocked",
|
|
271
|
+
message: 'Select status for "Blocked" (optional, for blocked tasks):',
|
|
272
|
+
choices: blockedChoices,
|
|
273
|
+
initial: defaults.blocked
|
|
274
|
+
? blockedChoices.findIndex((c) => c.value === defaults.blocked)
|
|
275
|
+
: 0,
|
|
276
|
+
});
|
|
204
277
|
return {
|
|
205
278
|
todo: todoResponse.todo || defaults.todo,
|
|
206
279
|
in_progress: inProgressResponse.in_progress || defaults.in_progress,
|
|
207
280
|
done: doneResponse.done || defaults.done,
|
|
208
281
|
testing: testingResponse.testing,
|
|
282
|
+
blocked: blockedResponse.blocked,
|
|
209
283
|
};
|
|
210
284
|
}
|
|
285
|
+
async function updateGitignore(tttDir, interactive) {
|
|
286
|
+
const gitignorePath = ".gitignore";
|
|
287
|
+
const entry = `${tttDir}/`;
|
|
288
|
+
try {
|
|
289
|
+
let content = "";
|
|
290
|
+
let exists = false;
|
|
291
|
+
try {
|
|
292
|
+
content = await fs.readFile(gitignorePath, "utf-8");
|
|
293
|
+
exists = true;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// .gitignore doesn't exist
|
|
297
|
+
}
|
|
298
|
+
// Check if already ignored
|
|
299
|
+
const lines = content.split("\n");
|
|
300
|
+
const alreadyIgnored = lines.some((line) => line.trim() === entry ||
|
|
301
|
+
line.trim() === tttDir ||
|
|
302
|
+
line.trim() === `/${entry}` ||
|
|
303
|
+
line.trim() === `/${tttDir}`);
|
|
304
|
+
if (alreadyIgnored) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Ask user in interactive mode
|
|
308
|
+
if (interactive) {
|
|
309
|
+
const { addToGitignore } = await prompts({
|
|
310
|
+
type: "confirm",
|
|
311
|
+
name: "addToGitignore",
|
|
312
|
+
message: `Add ${entry} to .gitignore?`,
|
|
313
|
+
initial: true,
|
|
314
|
+
});
|
|
315
|
+
if (!addToGitignore)
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// Add to .gitignore
|
|
319
|
+
const newContent = exists
|
|
320
|
+
? content.endsWith("\n")
|
|
321
|
+
? `${content}${entry}\n`
|
|
322
|
+
: `${content}\n${entry}\n`
|
|
323
|
+
: `${entry}\n`;
|
|
324
|
+
await fs.writeFile(gitignorePath, newContent, "utf-8");
|
|
325
|
+
console.log(` ā Added ${entry} to .gitignore`);
|
|
326
|
+
}
|
|
327
|
+
catch (_error) {
|
|
328
|
+
// Silently ignore gitignore errors
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function installClaudeCommands(interactive, statusSource) {
|
|
332
|
+
if (!interactive) {
|
|
333
|
+
return { installed: false, prefix: "" };
|
|
334
|
+
}
|
|
335
|
+
console.log("\nš¤ Claude Code Commands:");
|
|
336
|
+
// Ask if user wants to install commands
|
|
337
|
+
const { install } = await prompts({
|
|
338
|
+
type: "confirm",
|
|
339
|
+
name: "install",
|
|
340
|
+
message: "Install Claude Code commands? (work-on, done-job, sync-linear)",
|
|
341
|
+
initial: true,
|
|
342
|
+
});
|
|
343
|
+
if (!install) {
|
|
344
|
+
return { installed: false, prefix: "" };
|
|
345
|
+
}
|
|
346
|
+
// Ask for prefix
|
|
347
|
+
const { prefixChoice } = await prompts({
|
|
348
|
+
type: "select",
|
|
349
|
+
name: "prefixChoice",
|
|
350
|
+
message: "Command prefix style:",
|
|
351
|
+
choices: [
|
|
352
|
+
{
|
|
353
|
+
title: "No prefix (recommended)",
|
|
354
|
+
value: "",
|
|
355
|
+
description: "/work-on, /done-job, /sync-linear",
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
title: "ttt:",
|
|
359
|
+
value: "ttt:",
|
|
360
|
+
description: "/ttt:work-on, /ttt:done-job, /ttt:sync-linear",
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
title: "linear:",
|
|
364
|
+
value: "linear:",
|
|
365
|
+
description: "/linear:work-on, /linear:done-job, /linear:sync-linear",
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
title: "Custom...",
|
|
369
|
+
value: "custom",
|
|
370
|
+
description: "Enter your own prefix",
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
initial: 0,
|
|
374
|
+
});
|
|
375
|
+
let prefix = prefixChoice || "";
|
|
376
|
+
if (prefixChoice === "custom") {
|
|
377
|
+
const { customPrefix } = await prompts({
|
|
378
|
+
type: "text",
|
|
379
|
+
name: "customPrefix",
|
|
380
|
+
message: "Enter custom prefix (e.g., 'my:'):",
|
|
381
|
+
initial: "",
|
|
382
|
+
});
|
|
383
|
+
prefix = customPrefix || "";
|
|
384
|
+
}
|
|
385
|
+
// Find templates directory
|
|
386
|
+
// Try multiple locations: installed package, local dev
|
|
387
|
+
const possibleTemplatePaths = [
|
|
388
|
+
path.join(__dirname, "..", "templates", "claude-code-commands"),
|
|
389
|
+
path.join(__dirname, "..", "..", "templates", "claude-code-commands"),
|
|
390
|
+
path.join(process.cwd(), "templates", "claude-code-commands"),
|
|
391
|
+
];
|
|
392
|
+
let templateDir = null;
|
|
393
|
+
for (const p of possibleTemplatePaths) {
|
|
394
|
+
try {
|
|
395
|
+
await fs.access(p);
|
|
396
|
+
templateDir = p;
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
// Try next path
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (!templateDir) {
|
|
404
|
+
// Try to get repo URL from package.json
|
|
405
|
+
let repoUrl = "https://github.com/wayne930242/team-toon-tack";
|
|
406
|
+
try {
|
|
407
|
+
const pkgPaths = [
|
|
408
|
+
path.join(__dirname, "..", "package.json"),
|
|
409
|
+
path.join(__dirname, "..", "..", "package.json"),
|
|
410
|
+
];
|
|
411
|
+
for (const pkgPath of pkgPaths) {
|
|
412
|
+
try {
|
|
413
|
+
const pkgContent = await fs.readFile(pkgPath, "utf-8");
|
|
414
|
+
const pkg = JSON.parse(pkgContent);
|
|
415
|
+
if (pkg.repository?.url) {
|
|
416
|
+
// Parse git+https://github.com/user/repo.git format
|
|
417
|
+
repoUrl = pkg.repository.url
|
|
418
|
+
.replace(/^git\+/, "")
|
|
419
|
+
.replace(/\.git$/, "");
|
|
420
|
+
}
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// Try next path
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// Use default URL
|
|
430
|
+
}
|
|
431
|
+
console.log(" ā Could not find command templates. Please copy manually from:");
|
|
432
|
+
console.log(` ${repoUrl}/tree/main/templates/claude-code-commands`);
|
|
433
|
+
return { installed: false, prefix };
|
|
434
|
+
}
|
|
435
|
+
// Create .claude/commands directory
|
|
436
|
+
const commandsDir = path.join(process.cwd(), ".claude", "commands");
|
|
437
|
+
await fs.mkdir(commandsDir, { recursive: true });
|
|
438
|
+
// Copy and rename template files
|
|
439
|
+
const templateFiles = await fs.readdir(templateDir);
|
|
440
|
+
const commandFiles = templateFiles.filter((f) => f.endsWith(".md"));
|
|
441
|
+
for (const file of commandFiles) {
|
|
442
|
+
const baseName = file.replace(".md", "");
|
|
443
|
+
const newFileName = prefix ? `${prefix}${baseName}.md` : file;
|
|
444
|
+
const srcPath = path.join(templateDir, file);
|
|
445
|
+
const destPath = path.join(commandsDir, newFileName);
|
|
446
|
+
// Read template content
|
|
447
|
+
let content = await fs.readFile(srcPath, "utf-8");
|
|
448
|
+
// Update the name in frontmatter if prefix is used
|
|
449
|
+
if (prefix) {
|
|
450
|
+
content = content.replace(/^(---\s*\n[\s\S]*?name:\s*)(\S+)/m, `$1${prefix}${baseName}`);
|
|
451
|
+
}
|
|
452
|
+
// Modify content based on statusSource for work-on and done-job
|
|
453
|
+
if (statusSource === "local") {
|
|
454
|
+
if (baseName === "work-on" || baseName.endsWith("work-on")) {
|
|
455
|
+
// Update description for local mode
|
|
456
|
+
content = content.replace(/Select a task and update status to "In Progress" on both local and Linear\./, 'Select a task and update local status to "In Progress". (Linear will be updated when you run `sync --update`)');
|
|
457
|
+
// Add reminder after Complete section
|
|
458
|
+
content = content.replace(/Use `?\/done-job`? to mark task as completed/, "Use `/done-job` to mark task as completed\n\n### 7. Sync to Linear\n\nWhen ready to update Linear with all your changes:\n\n```bash\nttt sync --update\n```");
|
|
459
|
+
}
|
|
460
|
+
if (baseName === "done-job" || baseName.endsWith("done-job")) {
|
|
461
|
+
// Update description for local mode
|
|
462
|
+
content = content.replace(/Mark a task as done and update Linear with commit details\./, "Mark a task as done locally. (Run `ttt sync --update` to push changes to Linear)");
|
|
463
|
+
// Add reminder at the end
|
|
464
|
+
content = content.replace(/## What It Does\n\n- Linear issue status ā "Done"/, "## What It Does\n\n- Local status ā `completed`");
|
|
465
|
+
content += `\n## Sync to Linear\n\nAfter completing tasks, push all changes to Linear:\n\n\`\`\`bash\nttt sync --update\n\`\`\`\n`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
await fs.writeFile(destPath, content, "utf-8");
|
|
469
|
+
console.log(` ā .claude/commands/${newFileName}`);
|
|
470
|
+
}
|
|
471
|
+
return { installed: true, prefix };
|
|
472
|
+
}
|
|
211
473
|
async function init() {
|
|
212
474
|
const args = process.argv.slice(2);
|
|
213
475
|
const options = parseArgs(args);
|
|
@@ -262,24 +524,62 @@ async function init() {
|
|
|
262
524
|
if (selectedTeams.length > 1) {
|
|
263
525
|
console.log(` Primary: ${primaryTeam.name}`);
|
|
264
526
|
}
|
|
265
|
-
// Fetch data from primary team
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
527
|
+
// Fetch data from ALL teams (not just primary) to support cross-team operations
|
|
528
|
+
console.log(` Fetching data from ${teams.length} teams...`);
|
|
529
|
+
// Collect users, labels, states from all teams
|
|
530
|
+
const allUsers = [];
|
|
531
|
+
const allLabels = [];
|
|
532
|
+
const allStates = [];
|
|
533
|
+
const seenUserIds = new Set();
|
|
534
|
+
const seenLabelIds = new Set();
|
|
535
|
+
const seenStateIds = new Set();
|
|
536
|
+
for (const team of teams) {
|
|
537
|
+
try {
|
|
538
|
+
const teamData = await client.team(team.id);
|
|
539
|
+
const members = await teamData.members();
|
|
540
|
+
for (const user of members.nodes) {
|
|
541
|
+
if (!seenUserIds.has(user.id)) {
|
|
542
|
+
seenUserIds.add(user.id);
|
|
543
|
+
allUsers.push(user);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const labelsData = await client.issueLabels({
|
|
547
|
+
filter: { team: { id: { eq: team.id } } },
|
|
548
|
+
});
|
|
549
|
+
for (const label of labelsData.nodes) {
|
|
550
|
+
if (!seenLabelIds.has(label.id)) {
|
|
551
|
+
seenLabelIds.add(label.id);
|
|
552
|
+
allLabels.push(label);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const statesData = await client.workflowStates({
|
|
556
|
+
filter: { team: { id: { eq: team.id } } },
|
|
557
|
+
});
|
|
558
|
+
for (const state of statesData.nodes) {
|
|
559
|
+
if (!seenStateIds.has(state.id)) {
|
|
560
|
+
seenStateIds.add(state.id);
|
|
561
|
+
allStates.push(state);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
console.warn(` ā Could not fetch data for team ${team.name}, skipping...`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const users = allUsers;
|
|
570
|
+
const labels = allLabels;
|
|
571
|
+
const states = allStates;
|
|
269
572
|
console.log(` Users: ${users.length}`);
|
|
270
|
-
const labelsData = await client.issueLabels({
|
|
271
|
-
filter: { team: { id: { eq: primaryTeam.id } } },
|
|
272
|
-
});
|
|
273
|
-
const labels = labelsData.nodes;
|
|
274
573
|
console.log(` Labels: ${labels.length}`);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const states = statesData.nodes;
|
|
574
|
+
console.log(` Workflow states: ${states.length}`);
|
|
575
|
+
// Get cycle from primary team (for current work tracking)
|
|
576
|
+
const selectedTeam = await client.team(primaryTeam.id);
|
|
279
577
|
const currentCycle = (await selectedTeam.activeCycle);
|
|
280
578
|
// User selections
|
|
281
579
|
const currentUser = await selectUser(users, options);
|
|
282
580
|
const defaultLabel = await selectLabelFilter(labels, options);
|
|
581
|
+
const statusSource = await selectStatusSource(options);
|
|
582
|
+
const qaPmTeam = await selectQaPmTeam(teams, primaryTeam, options);
|
|
283
583
|
const statusTransitions = await selectStatusMappings(states, options);
|
|
284
584
|
// Build config
|
|
285
585
|
const config = buildConfig(teams, users, labels, states, statusTransitions, currentCycle ?? undefined);
|
|
@@ -289,7 +589,11 @@ async function init() {
|
|
|
289
589
|
const selectedTeamKeys = selectedTeams
|
|
290
590
|
.map((team) => findTeamKey(config.teams, team.id))
|
|
291
591
|
.filter((key) => key !== undefined);
|
|
292
|
-
const
|
|
592
|
+
const qaPmTeamKey = qaPmTeam
|
|
593
|
+
? findTeamKey(config.teams, qaPmTeam.id)
|
|
594
|
+
: undefined;
|
|
595
|
+
const localConfig = buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, undefined, // excludeLabels
|
|
596
|
+
statusSource, qaPmTeamKey);
|
|
293
597
|
// Write config files
|
|
294
598
|
console.log("\nš Writing configuration files...");
|
|
295
599
|
await fs.mkdir(paths.baseDir, { recursive: true });
|
|
@@ -326,10 +630,14 @@ async function init() {
|
|
|
326
630
|
localConfig.team = existingLocal.team;
|
|
327
631
|
if (existingLocal.teams)
|
|
328
632
|
localConfig.teams = existingLocal.teams;
|
|
633
|
+
if (existingLocal.qa_pm_team)
|
|
634
|
+
localConfig.qa_pm_team = existingLocal.qa_pm_team;
|
|
329
635
|
if (existingLocal.label)
|
|
330
636
|
localConfig.label = existingLocal.label;
|
|
331
637
|
if (existingLocal.exclude_labels)
|
|
332
638
|
localConfig.exclude_labels = existingLocal.exclude_labels;
|
|
639
|
+
if (existingLocal.status_source)
|
|
640
|
+
localConfig.status_source = existingLocal.status_source;
|
|
333
641
|
}
|
|
334
642
|
}
|
|
335
643
|
catch {
|
|
@@ -338,6 +646,11 @@ async function init() {
|
|
|
338
646
|
}
|
|
339
647
|
await fs.writeFile(paths.localPath, encode(localConfig), "utf-8");
|
|
340
648
|
console.log(` ā ${paths.localPath}`);
|
|
649
|
+
// Update .gitignore
|
|
650
|
+
const tttDir = paths.baseDir.replace(/^\.\//, "");
|
|
651
|
+
await updateGitignore(tttDir, options.interactive ?? true);
|
|
652
|
+
// Install Claude Code commands
|
|
653
|
+
const { installed: commandsInstalled, prefix: commandPrefix } = await installClaudeCommands(options.interactive ?? true, statusSource);
|
|
341
654
|
// Summary
|
|
342
655
|
console.log("\nā
Initialization complete!\n");
|
|
343
656
|
console.log("Configuration summary:");
|
|
@@ -347,6 +660,10 @@ async function init() {
|
|
|
347
660
|
}
|
|
348
661
|
console.log(` User: ${currentUser.displayName || currentUser.name} (${currentUser.email})`);
|
|
349
662
|
console.log(` Label filter: ${defaultLabel || "(none)"}`);
|
|
663
|
+
console.log(` Status source: ${statusSource === "local" ? "local (use 'sync --update' to push)" : "remote (immediate sync)"}`);
|
|
664
|
+
if (qaPmTeam) {
|
|
665
|
+
console.log(` QA/PM team: ${qaPmTeam.name}`);
|
|
666
|
+
}
|
|
350
667
|
console.log(` (Use 'ttt config filters' to set excluded labels/users)`);
|
|
351
668
|
if (currentCycle) {
|
|
352
669
|
console.log(` Cycle: ${currentCycle.name || `Cycle #${currentCycle.number}`}`);
|
|
@@ -358,10 +675,24 @@ async function init() {
|
|
|
358
675
|
if (statusTransitions.testing) {
|
|
359
676
|
console.log(` Testing: ${statusTransitions.testing}`);
|
|
360
677
|
}
|
|
678
|
+
if (statusTransitions.blocked) {
|
|
679
|
+
console.log(` Blocked: ${statusTransitions.blocked}`);
|
|
680
|
+
}
|
|
681
|
+
if (commandsInstalled) {
|
|
682
|
+
const cmdPrefix = commandPrefix ? `${commandPrefix}` : "";
|
|
683
|
+
console.log(` Claude commands: /${cmdPrefix}work-on, /${cmdPrefix}done-job, /${cmdPrefix}sync-linear`);
|
|
684
|
+
}
|
|
361
685
|
console.log("\nNext steps:");
|
|
362
686
|
console.log(" 1. Set LINEAR_API_KEY in your shell profile:");
|
|
363
687
|
console.log(` export LINEAR_API_KEY="${apiKey}"`);
|
|
364
|
-
console.log(" 2. Run sync:
|
|
365
|
-
|
|
688
|
+
console.log(" 2. Run sync: ttt sync");
|
|
689
|
+
if (commandsInstalled) {
|
|
690
|
+
const cmdPrefix = commandPrefix ? `${commandPrefix}` : "";
|
|
691
|
+
console.log(` 3. In Claude Code: /${cmdPrefix}work-on next`);
|
|
692
|
+
console.log(`\nš” Tip: Edit .claude/commands/${cmdPrefix}work-on.md to customize the "Verify" section for your project.`);
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
console.log(" 3. Start working: ttt work-on");
|
|
696
|
+
}
|
|
366
697
|
}
|
|
367
698
|
init().catch(console.error);
|
|
@@ -38,4 +38,4 @@ export declare function getDefaultStatusTransitions(states: LinearState[]): Stat
|
|
|
38
38
|
export declare function buildConfig(teams: LinearTeam[], users: LinearUser[], labels: LinearLabel[], states: LinearState[], statusTransitions: StatusTransitions, currentCycle?: LinearCycle): Config;
|
|
39
39
|
export declare function findUserKey(usersConfig: Record<string, UserConfig>, userId: string): string;
|
|
40
40
|
export declare function findTeamKey(teamsConfig: Record<string, TeamConfig>, teamId: string): string;
|
|
41
|
-
export declare function buildLocalConfig(currentUserKey: string, primaryTeamKey: string, selectedTeamKeys: string[], defaultLabel?: string, excludeLabels?: string[]): LocalConfig;
|
|
41
|
+
export declare function buildLocalConfig(currentUserKey: string, primaryTeamKey: string, selectedTeamKeys: string[], defaultLabel?: string, excludeLabels?: string[], statusSource?: "remote" | "local", qaPmTeam?: string): LocalConfig;
|
|
@@ -67,11 +67,13 @@ export function getDefaultStatusTransitions(states) {
|
|
|
67
67
|
findStatusByKeyword(states, ["done", "complete"]) ||
|
|
68
68
|
"Done";
|
|
69
69
|
const defaultTesting = findStatusByKeyword(states, ["testing", "review"]) || undefined;
|
|
70
|
+
const defaultBlocked = findStatusByKeyword(states, ["blocked", "on hold", "waiting"]) || undefined;
|
|
70
71
|
return {
|
|
71
72
|
todo: defaultTodo,
|
|
72
73
|
in_progress: defaultInProgress,
|
|
73
74
|
done: defaultDone,
|
|
74
75
|
testing: defaultTesting,
|
|
76
|
+
blocked: defaultBlocked,
|
|
75
77
|
};
|
|
76
78
|
}
|
|
77
79
|
export function buildConfig(teams, users, labels, states, statusTransitions, currentCycle) {
|
|
@@ -106,12 +108,14 @@ export function findTeamKey(teamsConfig, teamId) {
|
|
|
106
108
|
return (Object.entries(teamsConfig).find(([_, t]) => t.id === teamId)?.[0] ||
|
|
107
109
|
Object.keys(teamsConfig)[0]);
|
|
108
110
|
}
|
|
109
|
-
export function buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, excludeLabels) {
|
|
111
|
+
export function buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, excludeLabels, statusSource, qaPmTeam) {
|
|
110
112
|
return {
|
|
111
113
|
current_user: currentUserKey,
|
|
112
114
|
team: primaryTeamKey,
|
|
113
115
|
teams: selectedTeamKeys.length > 1 ? selectedTeamKeys : undefined,
|
|
116
|
+
qa_pm_team: qaPmTeam,
|
|
114
117
|
label: defaultLabel,
|
|
115
118
|
exclude_labels: excludeLabels && excludeLabels.length > 0 ? excludeLabels : undefined,
|
|
119
|
+
status_source: statusSource,
|
|
116
120
|
};
|
|
117
121
|
}
|
|
@@ -11,7 +11,7 @@ export function getStatusIcon(localStatus) {
|
|
|
11
11
|
return "ā
";
|
|
12
12
|
case "in-progress":
|
|
13
13
|
return "š";
|
|
14
|
-
case "blocked
|
|
14
|
+
case "blocked":
|
|
15
15
|
return "š«";
|
|
16
16
|
default:
|
|
17
17
|
return "š";
|
|
@@ -39,14 +39,25 @@ export function displayTaskStatus(task) {
|
|
|
39
39
|
}
|
|
40
40
|
export function displayTaskDescription(task) {
|
|
41
41
|
if (task.description) {
|
|
42
|
-
|
|
42
|
+
let description = task.description;
|
|
43
|
+
// Replace Linear URLs with local paths from attachments
|
|
44
|
+
if (task.attachments) {
|
|
45
|
+
for (const att of task.attachments) {
|
|
46
|
+
if (att.localPath && att.url) {
|
|
47
|
+
description = description.split(att.url).join(att.localPath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
console.log(`\nš Description:\n${description}`);
|
|
43
52
|
}
|
|
44
53
|
}
|
|
45
54
|
export function displayTaskAttachments(task) {
|
|
46
55
|
if (task.attachments && task.attachments.length > 0) {
|
|
47
56
|
console.log(`\nš Attachments:`);
|
|
48
57
|
for (const att of task.attachments) {
|
|
49
|
-
|
|
58
|
+
// Prefer local path for Linear images (Linear URLs are not accessible)
|
|
59
|
+
const displayPath = att.localPath || att.url;
|
|
60
|
+
console.log(` - ${att.title}: ${displayPath}`);
|
|
50
61
|
}
|
|
51
62
|
}
|
|
52
63
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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>;
|
|
@@ -0,0 +1,107 @@
|
|
|
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;
|
|
28
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
29
|
+
const url = match[1];
|
|
30
|
+
if (isLinearImageUrl(url) && !urls.includes(url)) {
|
|
31
|
+
urls.push(url);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return urls;
|
|
36
|
+
}
|
|
37
|
+
function getFileExtension(url, contentType) {
|
|
38
|
+
// Try to get extension from content-type header
|
|
39
|
+
if (contentType) {
|
|
40
|
+
// Image types
|
|
41
|
+
const imageMatch = contentType.match(/image\/(\w+)/);
|
|
42
|
+
if (imageMatch) {
|
|
43
|
+
const ext = imageMatch[1] === "jpeg" ? "jpg" : imageMatch[1];
|
|
44
|
+
return { ext, isImage: true };
|
|
45
|
+
}
|
|
46
|
+
// Video types
|
|
47
|
+
const videoMatch = contentType.match(/video\/(\w+)/);
|
|
48
|
+
if (videoMatch) {
|
|
49
|
+
const videoExt = videoMatch[1] === "quicktime" ? "mov" : videoMatch[1];
|
|
50
|
+
return { ext: videoExt, isImage: false };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Fallback: try to get from URL path
|
|
54
|
+
const urlPath = new URL(url).pathname;
|
|
55
|
+
const ext = path.extname(urlPath).slice(1).toLowerCase();
|
|
56
|
+
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
|
|
57
|
+
return { ext: ext === "jpeg" ? "jpg" : ext, isImage: true };
|
|
58
|
+
}
|
|
59
|
+
if (["mov", "mp4", "webm", "avi"].includes(ext)) {
|
|
60
|
+
return { ext, isImage: false };
|
|
61
|
+
}
|
|
62
|
+
return { ext: "png", isImage: true }; // Default assume image
|
|
63
|
+
}
|
|
64
|
+
export async function downloadLinearFile(url, issueId, attachmentId, outputDir) {
|
|
65
|
+
try {
|
|
66
|
+
// Linear files require authentication
|
|
67
|
+
const headers = {};
|
|
68
|
+
if (process.env.LINEAR_API_KEY) {
|
|
69
|
+
headers["Authorization"] = process.env.LINEAR_API_KEY;
|
|
70
|
+
}
|
|
71
|
+
const response = await fetch(url, { headers });
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
console.error(`Failed to download file: ${response.status}`);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const contentType = response.headers.get("content-type") || undefined;
|
|
77
|
+
const { ext } = getFileExtension(url, contentType);
|
|
78
|
+
const filename = `${issueId}_${attachmentId}.${ext}`;
|
|
79
|
+
const filepath = path.join(outputDir, filename);
|
|
80
|
+
const buffer = await response.arrayBuffer();
|
|
81
|
+
await fs.writeFile(filepath, Buffer.from(buffer));
|
|
82
|
+
return filepath;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
console.error(`Error downloading file: ${error}`);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Alias for backwards compatibility
|
|
90
|
+
export const downloadLinearImage = downloadLinearFile;
|
|
91
|
+
export async function clearIssueImages(outputDir, issueId) {
|
|
92
|
+
try {
|
|
93
|
+
const files = await fs.readdir(outputDir);
|
|
94
|
+
const issuePrefix = `${issueId}_`;
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
if (file.startsWith(issuePrefix)) {
|
|
97
|
+
await fs.unlink(path.join(outputDir, file));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Directory doesn't exist or other error, ignore
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function ensureOutputDir(outputDir) {
|
|
106
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
107
|
+
}
|
|
@@ -8,4 +8,4 @@ export declare function getWorkflowStates(config: Config, teamKey: string): Prom
|
|
|
8
8
|
export declare function getStatusTransitions(config: Config): StatusTransitions;
|
|
9
9
|
export declare function updateIssueStatus(linearId: string, targetStatusName: string, config: Config, teamKey: string): Promise<boolean>;
|
|
10
10
|
export declare function addComment(issueId: string, body: string): Promise<boolean>;
|
|
11
|
-
export declare function mapLocalStatusToLinear(localStatus: "pending" | "in-progress" | "completed" | "blocked
|
|
11
|
+
export declare function mapLocalStatusToLinear(localStatus: "pending" | "in-progress" | "completed" | "blocked", config: Config): string | undefined;
|