team-toon-tack 2.0.3 → 2.1.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/README.md +15 -2
- package/README.zh-TW.md +15 -2
- package/dist/scripts/config/teams.js +166 -34
- package/dist/scripts/done-job.js +158 -44
- package/dist/scripts/init.js +170 -116
- package/dist/scripts/lib/config-builder.d.ts +2 -2
- package/dist/scripts/lib/config-builder.js +5 -4
- package/dist/scripts/lib/images.js +4 -3
- package/dist/scripts/utils.d.ts +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,8 @@ Optimized Linear workflow for Claude Code — saves significant tokens compared
|
|
|
10
10
|
- **Smart Task Selection** — Auto-pick highest priority unassigned work with `/work-on next`
|
|
11
11
|
- **Multi-team Support** — Sync and filter issues across multiple teams
|
|
12
12
|
- **Flexible Sync Modes** — Choose between remote (immediate Linear sync) or local (offline-first, sync later with `--update`)
|
|
13
|
-
- **
|
|
13
|
+
- **Completion Modes** — Four modes for task completion: simple, strict review, upstream strict, upstream not strict
|
|
14
|
+
- **QA Team Support** — Auto-update parent issues in QA team to "Testing" when completing dev tasks
|
|
14
15
|
- **Attachment Download** — Auto-download Linear images and files to local `.ttt/output/` for AI vision analysis
|
|
15
16
|
- **Blocked Status** — Set tasks as blocked when waiting on external dependencies
|
|
16
17
|
- **Auto Command Setup** — `ttt init` can install Claude Code commands with custom prefix
|
|
@@ -30,10 +31,22 @@ ttt init
|
|
|
30
31
|
```
|
|
31
32
|
|
|
32
33
|
During init, you'll configure:
|
|
34
|
+
- **Dev team**: Your development team (single selection)
|
|
35
|
+
- **Dev testing status**: Testing/review status for your dev team (optional)
|
|
36
|
+
- **QA team(s)**: For cross-team parent issue updates, each with its own testing status (optional)
|
|
37
|
+
- **Completion mode**: How task completion is handled (see below)
|
|
33
38
|
- **Status source**: `remote` (update Linear immediately) or `local` (work offline, sync with `ttt sync --update`)
|
|
34
|
-
- **QA/PM team**: For cross-team parent issue updates (parent must be set in Linear)
|
|
35
39
|
- **Claude Code commands**: Auto-install with optional prefix (e.g., `/ttt:work-on`)
|
|
36
40
|
|
|
41
|
+
### Completion Modes
|
|
42
|
+
|
|
43
|
+
| Mode | Behavior |
|
|
44
|
+
|------|----------|
|
|
45
|
+
| `simple` | Mark task as Done + parent as Done. Default when no QA team configured. |
|
|
46
|
+
| `strict_review` | Mark task to dev testing + parent to QA testing. |
|
|
47
|
+
| `upstream_strict` | Mark task as Done + parent to Testing. Falls back to dev testing if no parent. Default when QA team configured. |
|
|
48
|
+
| `upstream_not_strict` | Mark task as Done + parent to Testing. No fallback if no parent. |
|
|
49
|
+
|
|
37
50
|
### 2. Daily Workflow
|
|
38
51
|
|
|
39
52
|
In Claude Code:
|
package/README.zh-TW.md
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
- **智慧任務挑選** — `/work-on next` 自動選擇最高優先級的未指派工作
|
|
11
11
|
- **多團隊支援** — 跨多個團隊同步與過濾 issue
|
|
12
12
|
- **彈性同步模式** — 選擇 remote(即時同步 Linear)或 local(離線優先,稍後用 `--update` 同步)
|
|
13
|
-
-
|
|
13
|
+
- **完成模式** — 四種任務完成模式:簡單、嚴格審查、上下游嚴格、上下游非嚴格
|
|
14
|
+
- **QA 團隊支援** — 完成開發任務時自動將 QA 團隊的 parent issue 更新為「Testing」
|
|
14
15
|
- **附件下載** — 自動下載 Linear 圖片和檔案到本地 `.ttt/output/`,供 AI 視覺分析
|
|
15
16
|
- **阻塞狀態** — 等待外部依賴時可設定任務為 blocked
|
|
16
17
|
- **自動安裝指令** — `ttt init` 可自動安裝 Claude Code commands,支援自訂前綴
|
|
@@ -30,10 +31,22 @@ ttt init
|
|
|
30
31
|
```
|
|
31
32
|
|
|
32
33
|
初始化時會設定:
|
|
34
|
+
- **開發團隊**:你的開發團隊(單選)
|
|
35
|
+
- **開發團隊測試狀態**:開發團隊的 testing/review 狀態(可選)
|
|
36
|
+
- **QA 團隊**:跨團隊 parent issue 更新,各自設定 testing 狀態(可選)
|
|
37
|
+
- **完成模式**:任務完成時的處理方式(見下方說明)
|
|
33
38
|
- **狀態來源**:`remote`(即時更新 Linear)或 `local`(離線工作,用 `ttt sync --update` 同步)
|
|
34
|
-
- **QA/PM 團隊**:跨團隊 parent issue 更新(需在 Linear 設定 parent)
|
|
35
39
|
- **Claude Code commands**:自動安裝,可選前綴(如 `/ttt:work-on`)
|
|
36
40
|
|
|
41
|
+
### 完成模式
|
|
42
|
+
|
|
43
|
+
| 模式 | 行為 |
|
|
44
|
+
|------|------|
|
|
45
|
+
| `simple` | 任務標記為 Done,parent 也標記為 Done。未設定 QA 團隊時的預設值。 |
|
|
46
|
+
| `strict_review` | 任務標記到開發團隊的 testing 狀態,parent 標記到 QA 團隊的 testing 狀態。 |
|
|
47
|
+
| `upstream_strict` | 任務標記為 Done,parent 移動到 Testing。若無 parent,fallback 到開發團隊的 testing 狀態。設定 QA 團隊時的預設值。 |
|
|
48
|
+
| `upstream_not_strict` | 任務標記為 Done,parent 移動到 Testing。若無 parent 不做 fallback。 |
|
|
49
|
+
|
|
37
50
|
### 2. 每日工作流
|
|
38
51
|
|
|
39
52
|
在 Claude Code 中:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import prompts from "prompts";
|
|
2
2
|
import { getLinearClient, saveLocalConfig, } from "../utils.js";
|
|
3
|
+
import { getDefaultStatusTransitions } from "../lib/config-builder.js";
|
|
3
4
|
export async function configureTeams(_config, localConfig) {
|
|
4
5
|
console.log("👥 Configure Teams\n");
|
|
5
6
|
const client = getLinearClient();
|
|
@@ -9,51 +10,182 @@ export async function configureTeams(_config, localConfig) {
|
|
|
9
10
|
console.error("No teams found.");
|
|
10
11
|
process.exit(1);
|
|
11
12
|
}
|
|
12
|
-
//
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
name
|
|
16
|
-
|
|
13
|
+
// Build team key mapping
|
|
14
|
+
const teamKeyMap = new Map(); // id -> key
|
|
15
|
+
for (const team of teams) {
|
|
16
|
+
const key = team.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
|
|
17
|
+
teamKeyMap.set(team.id, key);
|
|
18
|
+
}
|
|
19
|
+
// Fetch workflow states for all teams
|
|
20
|
+
const teamStatesMap = new Map();
|
|
21
|
+
for (const team of teams) {
|
|
22
|
+
try {
|
|
23
|
+
const statesData = await client.workflowStates({
|
|
24
|
+
filter: { team: { id: { eq: team.id } } },
|
|
25
|
+
});
|
|
26
|
+
teamStatesMap.set(team.id, statesData.nodes);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
teamStatesMap.set(team.id, []);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// 1. Select dev team (single)
|
|
33
|
+
console.log("\n👨💻 Dev Team:");
|
|
34
|
+
const devTeamResponse = await prompts({
|
|
35
|
+
type: "select",
|
|
36
|
+
name: "teamId",
|
|
37
|
+
message: "Select your dev team:",
|
|
17
38
|
choices: teams.map((t) => {
|
|
18
|
-
const key = t.
|
|
19
|
-
const currentTeams = localConfig.teams || [localConfig.team];
|
|
39
|
+
const key = teamKeyMap.get(t.id) || "";
|
|
20
40
|
return {
|
|
21
41
|
title: t.name,
|
|
22
|
-
value:
|
|
23
|
-
selected:
|
|
42
|
+
value: t.id,
|
|
43
|
+
selected: key === localConfig.team,
|
|
24
44
|
};
|
|
25
45
|
}),
|
|
26
|
-
min: 1,
|
|
27
46
|
});
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
const devTeamId = devTeamResponse.teamId;
|
|
48
|
+
const devTeamKey = teamKeyMap.get(devTeamId) || localConfig.team;
|
|
49
|
+
const devTeam = teams.find((t) => t.id === devTeamId);
|
|
50
|
+
// 2. Select dev team testing status
|
|
51
|
+
const devStates = teamStatesMap.get(devTeamId) || [];
|
|
52
|
+
const devDefaults = getDefaultStatusTransitions(devStates);
|
|
53
|
+
console.log("\n🔍 Dev Team Testing/Review Status:");
|
|
54
|
+
const devTestingResponse = await prompts({
|
|
55
|
+
type: "select",
|
|
56
|
+
name: "testingStatus",
|
|
57
|
+
message: "Select testing/review status for dev team:",
|
|
58
|
+
choices: [
|
|
59
|
+
{ title: "(Skip - no testing status)", value: undefined },
|
|
60
|
+
...devStates.map((s) => ({
|
|
61
|
+
title: `${s.name} (${s.type})`,
|
|
62
|
+
value: s.name,
|
|
39
63
|
})),
|
|
40
|
-
|
|
64
|
+
],
|
|
65
|
+
initial: localConfig.dev_testing_status
|
|
66
|
+
? devStates.findIndex((s) => s.name === localConfig.dev_testing_status) +
|
|
67
|
+
1
|
|
68
|
+
: devDefaults.testing
|
|
69
|
+
? devStates.findIndex((s) => s.name === devDefaults.testing) + 1
|
|
70
|
+
: 0,
|
|
71
|
+
});
|
|
72
|
+
const devTestingStatus = devTestingResponse.testingStatus;
|
|
73
|
+
// 3. Select QA/PM teams (multiple)
|
|
74
|
+
const otherTeams = teams.filter((t) => t.id !== devTeamId);
|
|
75
|
+
const qaPmTeams = [];
|
|
76
|
+
if (otherTeams.length > 0) {
|
|
77
|
+
console.log("\n🔗 QA/PM Teams:");
|
|
78
|
+
const qaPmResponse = await prompts({
|
|
79
|
+
type: "multiselect",
|
|
80
|
+
name: "teamIds",
|
|
81
|
+
message: "Select QA/PM teams for cross-team parent updates (space to select):",
|
|
82
|
+
choices: otherTeams.map((t) => {
|
|
83
|
+
const key = teamKeyMap.get(t.id) || "";
|
|
84
|
+
const currentQaPm = localConfig.qa_pm_teams || [];
|
|
85
|
+
return {
|
|
86
|
+
title: t.name,
|
|
87
|
+
value: t.id,
|
|
88
|
+
selected: currentQaPm.some((qp) => qp.team === key),
|
|
89
|
+
};
|
|
90
|
+
}),
|
|
91
|
+
hint: "- Press space to select, enter to confirm. Leave empty to skip.",
|
|
41
92
|
});
|
|
42
|
-
|
|
93
|
+
const selectedQaPmIds = qaPmResponse.teamIds || [];
|
|
94
|
+
// 4. For each QA/PM team, select testing status
|
|
95
|
+
for (const teamId of selectedQaPmIds) {
|
|
96
|
+
const team = teams.find((t) => t.id === teamId);
|
|
97
|
+
if (!team)
|
|
98
|
+
continue;
|
|
99
|
+
const teamKey = teamKeyMap.get(teamId) || "";
|
|
100
|
+
const teamStates = teamStatesMap.get(teamId) || [];
|
|
101
|
+
const defaults = getDefaultStatusTransitions(teamStates);
|
|
102
|
+
// Find existing config for this team
|
|
103
|
+
const existingConfig = localConfig.qa_pm_teams?.find((qp) => qp.team === teamKey);
|
|
104
|
+
const stateChoices = teamStates.map((s) => ({
|
|
105
|
+
title: `${s.name} (${s.type})`,
|
|
106
|
+
value: s.name,
|
|
107
|
+
}));
|
|
108
|
+
const statusResponse = await prompts({
|
|
109
|
+
type: "select",
|
|
110
|
+
name: "testingStatus",
|
|
111
|
+
message: `Select testing status for ${team.name}:`,
|
|
112
|
+
choices: [
|
|
113
|
+
{ title: "(Skip this team)", value: undefined },
|
|
114
|
+
...stateChoices,
|
|
115
|
+
],
|
|
116
|
+
initial: existingConfig?.testing_status
|
|
117
|
+
? stateChoices.findIndex((c) => c.value === existingConfig.testing_status) + 1
|
|
118
|
+
: defaults.testing
|
|
119
|
+
? stateChoices.findIndex((c) => c.value === defaults.testing) + 1
|
|
120
|
+
: 0,
|
|
121
|
+
});
|
|
122
|
+
if (statusResponse.testingStatus) {
|
|
123
|
+
qaPmTeams.push({
|
|
124
|
+
team: teamKey,
|
|
125
|
+
testing_status: statusResponse.testingStatus,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
43
129
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
130
|
+
// 5. Select completion mode
|
|
131
|
+
console.log("\n✅ Completion Mode:");
|
|
132
|
+
const currentMode = localConfig.completion_mode ||
|
|
133
|
+
(qaPmTeams.length > 0 ? "upstream_strict" : "simple");
|
|
134
|
+
const defaultModeIndex = currentMode === "simple"
|
|
135
|
+
? 0
|
|
136
|
+
: currentMode === "strict_review"
|
|
137
|
+
? 1
|
|
138
|
+
: currentMode === "upstream_strict"
|
|
139
|
+
? 2
|
|
140
|
+
: 3;
|
|
141
|
+
const modeResponse = await prompts({
|
|
142
|
+
type: "select",
|
|
143
|
+
name: "mode",
|
|
144
|
+
message: "How should tasks be completed?",
|
|
145
|
+
choices: [
|
|
146
|
+
{
|
|
147
|
+
title: "Simple",
|
|
148
|
+
value: "simple",
|
|
149
|
+
description: "Mark task as done directly",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
title: "Strict Review",
|
|
153
|
+
value: "strict_review",
|
|
154
|
+
description: "Mark task to dev team's testing status",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
title: "Upstream Strict (recommended with QA/PM)",
|
|
158
|
+
value: "upstream_strict",
|
|
159
|
+
description: "Done + parent to testing, fallback to testing if no parent",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
title: "Upstream Not Strict",
|
|
163
|
+
value: "upstream_not_strict",
|
|
164
|
+
description: "Done + parent to testing, no fallback",
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
initial: defaultModeIndex,
|
|
168
|
+
});
|
|
169
|
+
const completionMode = modeResponse.mode || (qaPmTeams.length > 0 ? "upstream_strict" : "simple");
|
|
170
|
+
// Update local config
|
|
171
|
+
localConfig.team = devTeamKey;
|
|
172
|
+
localConfig.dev_testing_status = devTestingStatus;
|
|
173
|
+
localConfig.qa_pm_teams = qaPmTeams.length > 0 ? qaPmTeams : undefined;
|
|
174
|
+
localConfig.completion_mode = completionMode;
|
|
175
|
+
// Remove deprecated fields
|
|
176
|
+
delete localConfig.teams;
|
|
177
|
+
delete localConfig.qa_pm_team;
|
|
50
178
|
await saveLocalConfig(localConfig);
|
|
51
179
|
console.log("\n✅ Teams updated:");
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
console.log(`
|
|
180
|
+
console.log(` Dev Team: ${devTeam?.name || devTeamKey}`);
|
|
181
|
+
if (devTestingStatus) {
|
|
182
|
+
console.log(` Dev Testing Status: ${devTestingStatus}`);
|
|
55
183
|
}
|
|
56
|
-
|
|
57
|
-
|
|
184
|
+
console.log(` Completion Mode: ${completionMode}`);
|
|
185
|
+
if (qaPmTeams.length > 0) {
|
|
186
|
+
console.log(" QA/PM Teams:");
|
|
187
|
+
for (const qp of qaPmTeams) {
|
|
188
|
+
console.log(` - ${qp.team}: ${qp.testing_status}`);
|
|
189
|
+
}
|
|
58
190
|
}
|
|
59
191
|
}
|
package/dist/scripts/done-job.js
CHANGED
|
@@ -3,6 +3,86 @@ import { buildCompletionComment, getLatestCommit } from "./lib/git.js";
|
|
|
3
3
|
import { addComment, getStatusTransitions, getWorkflowStates, updateIssueStatus, } from "./lib/linear.js";
|
|
4
4
|
import { syncSingleIssue } from "./lib/sync.js";
|
|
5
5
|
import { getLinearClient, loadConfig, loadCycleData, loadLocalConfig, } from "./utils.js";
|
|
6
|
+
// Helper function to update parent issue to a specific status
|
|
7
|
+
async function updateParentStatus(parentIssueId, targetStatus, _qaPmTeams, config) {
|
|
8
|
+
try {
|
|
9
|
+
const client = getLinearClient();
|
|
10
|
+
const searchResult = await client.searchIssues(parentIssueId);
|
|
11
|
+
const parentIssue = searchResult.nodes.find((issue) => issue.identifier === parentIssueId);
|
|
12
|
+
if (!parentIssue) {
|
|
13
|
+
return { success: false };
|
|
14
|
+
}
|
|
15
|
+
const parentTeam = await parentIssue.team;
|
|
16
|
+
if (!parentTeam) {
|
|
17
|
+
return { success: false };
|
|
18
|
+
}
|
|
19
|
+
// Find the matching team configuration
|
|
20
|
+
const teamEntries = Object.entries(config.teams);
|
|
21
|
+
const matchingTeamEntry = teamEntries.find(([_, t]) => t.id === parentTeam.id);
|
|
22
|
+
if (!matchingTeamEntry) {
|
|
23
|
+
return { success: false };
|
|
24
|
+
}
|
|
25
|
+
const [parentTeamKey] = matchingTeamEntry;
|
|
26
|
+
// Get workflow states for the parent's team
|
|
27
|
+
const parentStates = await getWorkflowStates(config, parentTeamKey);
|
|
28
|
+
const targetState = parentStates.find((s) => s.name === targetStatus);
|
|
29
|
+
if (!targetState) {
|
|
30
|
+
return { success: false };
|
|
31
|
+
}
|
|
32
|
+
// Update the parent issue
|
|
33
|
+
await client.updateIssue(parentIssue.id, {
|
|
34
|
+
stateId: targetState.id,
|
|
35
|
+
});
|
|
36
|
+
return { success: true, status: targetStatus };
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error("Failed to update parent issue:", error);
|
|
40
|
+
return { success: false };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Helper function to update parent issue to testing status (uses QA team config)
|
|
44
|
+
async function updateParentToTesting(parentIssueId, qaPmTeams, config) {
|
|
45
|
+
try {
|
|
46
|
+
const client = getLinearClient();
|
|
47
|
+
const searchResult = await client.searchIssues(parentIssueId);
|
|
48
|
+
const parentIssue = searchResult.nodes.find((issue) => issue.identifier === parentIssueId);
|
|
49
|
+
if (!parentIssue) {
|
|
50
|
+
return { success: false };
|
|
51
|
+
}
|
|
52
|
+
const parentTeam = await parentIssue.team;
|
|
53
|
+
if (!parentTeam) {
|
|
54
|
+
return { success: false };
|
|
55
|
+
}
|
|
56
|
+
// Find the matching QA/PM team configuration
|
|
57
|
+
const teamEntries = Object.entries(config.teams);
|
|
58
|
+
const matchingTeamEntry = teamEntries.find(([_, t]) => t.id === parentTeam.id);
|
|
59
|
+
if (!matchingTeamEntry) {
|
|
60
|
+
return { success: false };
|
|
61
|
+
}
|
|
62
|
+
const [parentTeamKey] = matchingTeamEntry;
|
|
63
|
+
// Find the QA/PM team config for this parent's team
|
|
64
|
+
const qaPmConfig = qaPmTeams.find((qp) => qp.team === parentTeamKey);
|
|
65
|
+
if (!qaPmConfig) {
|
|
66
|
+
// Parent's team is not in the configured QA/PM teams
|
|
67
|
+
return { success: false };
|
|
68
|
+
}
|
|
69
|
+
// Get workflow states for the parent's team
|
|
70
|
+
const parentStates = await getWorkflowStates(config, parentTeamKey);
|
|
71
|
+
const testingState = parentStates.find((s) => s.name === qaPmConfig.testing_status);
|
|
72
|
+
if (!testingState) {
|
|
73
|
+
return { success: false };
|
|
74
|
+
}
|
|
75
|
+
// Update the parent issue
|
|
76
|
+
await client.updateIssue(parentIssue.id, {
|
|
77
|
+
stateId: testingState.id,
|
|
78
|
+
});
|
|
79
|
+
return { success: true, testingStatus: qaPmConfig.testing_status };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
console.error("Failed to update parent issue:", error);
|
|
83
|
+
return { success: false };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
6
86
|
function parseArgs(args) {
|
|
7
87
|
let issueId;
|
|
8
88
|
let message;
|
|
@@ -110,10 +190,84 @@ Examples:
|
|
|
110
190
|
process.env.LINEAR_API_KEY &&
|
|
111
191
|
statusSource === "remote") {
|
|
112
192
|
const transitions = getStatusTransitions(config);
|
|
113
|
-
//
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
193
|
+
// Determine completion mode
|
|
194
|
+
const completionMode = localConfig.completion_mode ||
|
|
195
|
+
(localConfig.qa_pm_teams && localConfig.qa_pm_teams.length > 0
|
|
196
|
+
? "upstream_strict"
|
|
197
|
+
: "simple");
|
|
198
|
+
// Get the testing status to use (from dev team)
|
|
199
|
+
const devTestingStatus = localConfig.dev_testing_status || transitions.testing;
|
|
200
|
+
// Execute based on completion mode
|
|
201
|
+
let parentUpdateSuccess = false;
|
|
202
|
+
let parentTestingStatus;
|
|
203
|
+
switch (completionMode) {
|
|
204
|
+
case "simple": {
|
|
205
|
+
// Mark task as done
|
|
206
|
+
const success = await updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
|
|
207
|
+
if (success) {
|
|
208
|
+
console.log(`Linear: ${task.id} → ${transitions.done}`);
|
|
209
|
+
}
|
|
210
|
+
// Also mark parent as done if exists
|
|
211
|
+
if (task.parentIssueId) {
|
|
212
|
+
const result = await updateParentStatus(task.parentIssueId, transitions.done, localConfig.qa_pm_teams, config);
|
|
213
|
+
if (result.success) {
|
|
214
|
+
console.log(`Linear: Parent ${task.parentIssueId} → ${transitions.done}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "strict_review": {
|
|
220
|
+
// Mark task to dev team's testing status
|
|
221
|
+
if (devTestingStatus) {
|
|
222
|
+
const success = await updateIssueStatus(task.linearId, devTestingStatus, config, localConfig.team);
|
|
223
|
+
if (success) {
|
|
224
|
+
console.log(`Linear: ${task.id} → ${devTestingStatus}`);
|
|
225
|
+
}
|
|
226
|
+
// Also mark parent to testing if exists
|
|
227
|
+
if (task.parentIssueId && localConfig.qa_pm_teams?.length) {
|
|
228
|
+
const result = await updateParentToTesting(task.parentIssueId, localConfig.qa_pm_teams, config);
|
|
229
|
+
if (result.success) {
|
|
230
|
+
console.log(`Linear: Parent ${task.parentIssueId} → ${result.testingStatus}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
console.warn("No dev testing status configured, falling back to done");
|
|
236
|
+
const success = await updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
|
|
237
|
+
if (success) {
|
|
238
|
+
console.log(`Linear: ${task.id} → ${transitions.done}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "upstream_strict":
|
|
244
|
+
case "upstream_not_strict": {
|
|
245
|
+
// First, mark as done
|
|
246
|
+
const doneSuccess = await updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
|
|
247
|
+
if (doneSuccess) {
|
|
248
|
+
console.log(`Linear: ${task.id} → ${transitions.done}`);
|
|
249
|
+
}
|
|
250
|
+
// Try to update parent to testing
|
|
251
|
+
if (task.parentIssueId && localConfig.qa_pm_teams?.length) {
|
|
252
|
+
const result = await updateParentToTesting(task.parentIssueId, localConfig.qa_pm_teams, config);
|
|
253
|
+
parentUpdateSuccess = result.success;
|
|
254
|
+
parentTestingStatus = result.testingStatus;
|
|
255
|
+
if (parentUpdateSuccess) {
|
|
256
|
+
console.log(`Linear: Parent ${task.parentIssueId} → ${parentTestingStatus}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Fallback logic for upstream_strict
|
|
260
|
+
if (completionMode === "upstream_strict" &&
|
|
261
|
+
!parentUpdateSuccess &&
|
|
262
|
+
devTestingStatus) {
|
|
263
|
+
// No parent or parent update failed, fallback to testing
|
|
264
|
+
const fallbackSuccess = await updateIssueStatus(task.linearId, devTestingStatus, config, localConfig.team);
|
|
265
|
+
if (fallbackSuccess) {
|
|
266
|
+
console.log(`Linear: ${task.id} → ${devTestingStatus} (fallback, no valid parent)`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
117
271
|
}
|
|
118
272
|
// Add comment with commit info
|
|
119
273
|
if (commit) {
|
|
@@ -123,43 +277,6 @@ Examples:
|
|
|
123
277
|
console.log(`Linear: 已新增 commit 留言`);
|
|
124
278
|
}
|
|
125
279
|
}
|
|
126
|
-
// Update parent to Testing if exists
|
|
127
|
-
if (task.parentIssueId && transitions.testing) {
|
|
128
|
-
try {
|
|
129
|
-
const client = getLinearClient();
|
|
130
|
-
const searchResult = await client.searchIssues(task.parentIssueId);
|
|
131
|
-
const parentIssue = searchResult.nodes.find((issue) => issue.identifier === task.parentIssueId);
|
|
132
|
-
if (parentIssue) {
|
|
133
|
-
const parentTeam = await parentIssue.team;
|
|
134
|
-
if (parentTeam) {
|
|
135
|
-
// Determine which team key to use for parent's workflow states
|
|
136
|
-
// If qa_pm_team is configured and matches parent's team, use it
|
|
137
|
-
// Otherwise, try to find the team key from config
|
|
138
|
-
let parentTeamKey = localConfig.team;
|
|
139
|
-
const teamEntries = Object.entries(config.teams);
|
|
140
|
-
const matchingTeam = teamEntries.find(([_, t]) => t.id === parentTeam.id);
|
|
141
|
-
if (matchingTeam) {
|
|
142
|
-
parentTeamKey = matchingTeam[0];
|
|
143
|
-
}
|
|
144
|
-
else if (localConfig.qa_pm_team) {
|
|
145
|
-
// Fallback to qa_pm_team if configured
|
|
146
|
-
parentTeamKey = localConfig.qa_pm_team;
|
|
147
|
-
}
|
|
148
|
-
const parentStates = await getWorkflowStates(config, parentTeamKey);
|
|
149
|
-
const testingState = parentStates.find((s) => s.name === transitions.testing);
|
|
150
|
-
if (testingState) {
|
|
151
|
-
await client.updateIssue(parentIssue.id, {
|
|
152
|
-
stateId: testingState.id,
|
|
153
|
-
});
|
|
154
|
-
console.log(`Linear: Parent ${task.parentIssueId} → ${transitions.testing}`);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
catch (parentError) {
|
|
160
|
-
console.error("Failed to update parent issue:", parentError);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
280
|
}
|
|
164
281
|
else if (statusSource === "local") {
|
|
165
282
|
console.log(`Local: ${task.id} marked as completed`);
|
|
@@ -187,9 +304,6 @@ Examples:
|
|
|
187
304
|
if (aiMessage) {
|
|
188
305
|
console.log(`AI: ${aiMessage}`);
|
|
189
306
|
}
|
|
190
|
-
if (task.parentIssueId && config.status_transitions?.testing) {
|
|
191
|
-
console.log(`Parent: ${task.parentIssueId} → ${config.status_transitions.testing}`);
|
|
192
|
-
}
|
|
193
307
|
console.log(`\n🎉 任務完成!`);
|
|
194
308
|
}
|
|
195
309
|
doneJob().catch(console.error);
|
package/dist/scripts/init.js
CHANGED
|
@@ -6,7 +6,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
import { decode, encode } from "@toon-format/toon";
|
|
8
8
|
import prompts from "prompts";
|
|
9
|
-
import { buildConfig, buildLocalConfig, findTeamKey, findUserKey, getDefaultStatusTransitions, } from "./lib/config-builder.js";
|
|
9
|
+
import { buildConfig, buildLocalConfig, buildTeamsConfig, findTeamKey, findUserKey, getDefaultStatusTransitions, } from "./lib/config-builder.js";
|
|
10
10
|
import { fileExists, getLinearClient, getPaths, } from "./utils.js";
|
|
11
11
|
function parseArgs(args) {
|
|
12
12
|
const options = { interactive: true };
|
|
@@ -82,77 +82,157 @@ async function promptForApiKey(options) {
|
|
|
82
82
|
}
|
|
83
83
|
return apiKey;
|
|
84
84
|
}
|
|
85
|
-
async function
|
|
86
|
-
let
|
|
87
|
-
let primaryTeam = teams[0];
|
|
85
|
+
async function selectDevTeam(teams, options) {
|
|
86
|
+
let devTeam = teams[0];
|
|
88
87
|
if (options.team) {
|
|
89
88
|
const found = teams.find((t) => t.name.toLowerCase() === options.team?.toLowerCase());
|
|
90
89
|
if (found) {
|
|
91
|
-
|
|
92
|
-
primaryTeam = found;
|
|
90
|
+
devTeam = found;
|
|
93
91
|
}
|
|
94
92
|
}
|
|
95
93
|
else if (options.interactive && teams.length > 1) {
|
|
94
|
+
console.log("\n👨💻 Dev Team Configuration:");
|
|
96
95
|
const response = await prompts({
|
|
97
|
-
type: "
|
|
98
|
-
name: "
|
|
99
|
-
message: "Select
|
|
96
|
+
type: "select",
|
|
97
|
+
name: "teamId",
|
|
98
|
+
message: "Select your dev team (for work-on/done commands):",
|
|
100
99
|
choices: teams.map((t) => ({ title: t.name, value: t.id })),
|
|
101
|
-
min: 1,
|
|
102
100
|
});
|
|
103
|
-
if (response.
|
|
104
|
-
|
|
105
|
-
if (selectedTeams.length > 1) {
|
|
106
|
-
const primaryResponse = await prompts({
|
|
107
|
-
type: "select",
|
|
108
|
-
name: "primaryTeamId",
|
|
109
|
-
message: "Select your primary team (for work-on/done commands):",
|
|
110
|
-
choices: selectedTeams.map((t) => ({ title: t.name, value: t.id })),
|
|
111
|
-
});
|
|
112
|
-
primaryTeam =
|
|
113
|
-
selectedTeams.find((t) => t.id === primaryResponse.primaryTeamId) ||
|
|
114
|
-
selectedTeams[0];
|
|
115
|
-
}
|
|
116
|
-
else {
|
|
117
|
-
primaryTeam = selectedTeams[0];
|
|
118
|
-
}
|
|
101
|
+
if (response.teamId) {
|
|
102
|
+
devTeam = teams.find((t) => t.id === response.teamId) || teams[0];
|
|
119
103
|
}
|
|
120
104
|
}
|
|
121
|
-
return
|
|
105
|
+
return devTeam;
|
|
106
|
+
}
|
|
107
|
+
async function selectDevTestingStatus(devStates, options) {
|
|
108
|
+
if (!options.interactive || devStates.length === 0) {
|
|
109
|
+
return getDefaultStatusTransitions(devStates).testing;
|
|
110
|
+
}
|
|
111
|
+
console.log("\n🔍 Dev Team Testing/Review Status:");
|
|
112
|
+
const stateChoices = devStates.map((s) => ({
|
|
113
|
+
title: `${s.name} (${s.type})`,
|
|
114
|
+
value: s.name,
|
|
115
|
+
}));
|
|
116
|
+
const response = await prompts({
|
|
117
|
+
type: "select",
|
|
118
|
+
name: "testingStatus",
|
|
119
|
+
message: "Select testing/review status for dev team (used when strict_review mode):",
|
|
120
|
+
choices: [
|
|
121
|
+
{ title: "(Skip - no testing status)", value: undefined },
|
|
122
|
+
...stateChoices,
|
|
123
|
+
],
|
|
124
|
+
initial: 0,
|
|
125
|
+
});
|
|
126
|
+
return response.testingStatus;
|
|
122
127
|
}
|
|
123
|
-
async function
|
|
128
|
+
async function selectQaPmTeams(teams, devTeam, teamStatesMap, teamsConfig, options) {
|
|
124
129
|
// Only ask if there are multiple teams and interactive mode
|
|
125
130
|
if (!options.interactive || teams.length <= 1) {
|
|
126
|
-
return
|
|
131
|
+
return [];
|
|
127
132
|
}
|
|
128
|
-
// Filter out
|
|
129
|
-
const otherTeams = teams.filter((t) => t.id !==
|
|
133
|
+
// Filter out dev team from choices
|
|
134
|
+
const otherTeams = teams.filter((t) => t.id !== devTeam.id);
|
|
130
135
|
if (otherTeams.length === 0) {
|
|
131
|
-
return
|
|
136
|
+
return [];
|
|
132
137
|
}
|
|
133
|
-
console.log("\n🔗 QA/PM
|
|
138
|
+
console.log("\n🔗 QA/PM Teams Configuration:");
|
|
134
139
|
const response = await prompts({
|
|
135
|
-
type: "
|
|
136
|
-
name: "
|
|
137
|
-
message: "Select QA/PM
|
|
140
|
+
type: "multiselect",
|
|
141
|
+
name: "qaPmTeamIds",
|
|
142
|
+
message: "Select QA/PM teams for cross-team parent updates (space to select, enter to confirm):",
|
|
138
143
|
choices: [
|
|
139
|
-
{
|
|
140
|
-
title: "(None - skip)",
|
|
141
|
-
value: undefined,
|
|
142
|
-
description: "No cross-team parent updates",
|
|
143
|
-
},
|
|
144
144
|
...otherTeams.map((t) => ({
|
|
145
145
|
title: t.name,
|
|
146
146
|
value: t.id,
|
|
147
147
|
description: "Parent issues in this team will be updated to Testing",
|
|
148
148
|
})),
|
|
149
149
|
],
|
|
150
|
-
|
|
150
|
+
hint: "- Press space to select, enter to confirm. Leave empty to skip.",
|
|
151
151
|
});
|
|
152
|
-
if (!response.
|
|
153
|
-
return
|
|
152
|
+
if (!response.qaPmTeamIds || response.qaPmTeamIds.length === 0) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
// For each selected QA/PM team, select its testing status
|
|
156
|
+
const qaPmTeams = [];
|
|
157
|
+
for (const teamId of response.qaPmTeamIds) {
|
|
158
|
+
const team = teams.find((t) => t.id === teamId);
|
|
159
|
+
if (!team)
|
|
160
|
+
continue;
|
|
161
|
+
const teamStates = teamStatesMap.get(teamId) || [];
|
|
162
|
+
const defaults = getDefaultStatusTransitions(teamStates);
|
|
163
|
+
// Find team key
|
|
164
|
+
const teamKey = Object.entries(teamsConfig).find(([_, t]) => t.id === teamId)?.[0] ||
|
|
165
|
+
team.name.toLowerCase().replace(/[^a-z0-9]/g, "_");
|
|
166
|
+
if (teamStates.length === 0) {
|
|
167
|
+
// No states available, use default
|
|
168
|
+
if (defaults.testing) {
|
|
169
|
+
qaPmTeams.push({
|
|
170
|
+
team: teamKey,
|
|
171
|
+
testing_status: defaults.testing,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const stateChoices = teamStates.map((s) => ({
|
|
177
|
+
title: `${s.name} (${s.type})`,
|
|
178
|
+
value: s.name,
|
|
179
|
+
}));
|
|
180
|
+
const statusResponse = await prompts({
|
|
181
|
+
type: "select",
|
|
182
|
+
name: "testingStatus",
|
|
183
|
+
message: `Select testing status for ${team.name}:`,
|
|
184
|
+
choices: [
|
|
185
|
+
{ title: "(Skip this team)", value: undefined },
|
|
186
|
+
...stateChoices,
|
|
187
|
+
],
|
|
188
|
+
initial: defaults.testing
|
|
189
|
+
? stateChoices.findIndex((c) => c.value === defaults.testing) + 1
|
|
190
|
+
: 0,
|
|
191
|
+
});
|
|
192
|
+
if (statusResponse.testingStatus) {
|
|
193
|
+
qaPmTeams.push({
|
|
194
|
+
team: teamKey,
|
|
195
|
+
testing_status: statusResponse.testingStatus,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
154
198
|
}
|
|
155
|
-
return
|
|
199
|
+
return qaPmTeams;
|
|
200
|
+
}
|
|
201
|
+
async function selectCompletionMode(hasQaPmTeams, options) {
|
|
202
|
+
if (!options.interactive) {
|
|
203
|
+
return hasQaPmTeams ? "upstream_strict" : "simple";
|
|
204
|
+
}
|
|
205
|
+
console.log("\n✅ Completion Mode Configuration:");
|
|
206
|
+
const defaultMode = hasQaPmTeams ? 2 : 0; // upstream_strict if has QA/PM teams, else simple
|
|
207
|
+
const response = await prompts({
|
|
208
|
+
type: "select",
|
|
209
|
+
name: "mode",
|
|
210
|
+
message: "How should tasks be completed?",
|
|
211
|
+
choices: [
|
|
212
|
+
{
|
|
213
|
+
title: "Simple",
|
|
214
|
+
value: "simple",
|
|
215
|
+
description: "Mark task as done directly",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
title: "Strict Review",
|
|
219
|
+
value: "strict_review",
|
|
220
|
+
description: "Mark task to dev team's testing status",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
title: "Upstream Strict (recommended with QA/PM)",
|
|
224
|
+
value: "upstream_strict",
|
|
225
|
+
description: "Done + parent to testing, fallback to testing if no parent",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
title: "Upstream Not Strict",
|
|
229
|
+
value: "upstream_not_strict",
|
|
230
|
+
description: "Done + parent to testing, no fallback",
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
initial: defaultMode,
|
|
234
|
+
});
|
|
235
|
+
return response.mode || (hasQaPmTeams ? "upstream_strict" : "simple");
|
|
156
236
|
}
|
|
157
237
|
async function selectUser(users, options) {
|
|
158
238
|
let currentUser = users[0];
|
|
@@ -220,19 +300,14 @@ async function selectStatusSource(options) {
|
|
|
220
300
|
});
|
|
221
301
|
return response.statusSource || "remote";
|
|
222
302
|
}
|
|
223
|
-
async function selectStatusMappings(devStates,
|
|
303
|
+
async function selectStatusMappings(devStates, options) {
|
|
224
304
|
// Use dev team states for todo, in_progress, done, blocked
|
|
225
|
-
//
|
|
305
|
+
// Testing status is now configured separately (dev_testing_status and qa_pm_teams)
|
|
226
306
|
const devDefaults = getDefaultStatusTransitions(devStates);
|
|
227
|
-
const testingStates = qaStates && qaStates.length > 0 ? qaStates : devStates;
|
|
228
|
-
const testingDefaults = getDefaultStatusTransitions(testingStates);
|
|
229
307
|
if (!options.interactive || devStates.length === 0) {
|
|
230
|
-
return
|
|
231
|
-
...devDefaults,
|
|
232
|
-
testing: testingDefaults.testing,
|
|
233
|
-
};
|
|
308
|
+
return devDefaults;
|
|
234
309
|
}
|
|
235
|
-
console.log("\n📊 Configure status mappings:");
|
|
310
|
+
console.log("\n📊 Configure status mappings (dev team):");
|
|
236
311
|
const devStateChoices = devStates.map((s) => ({
|
|
237
312
|
title: `${s.name} (${s.type})`,
|
|
238
313
|
value: s.name,
|
|
@@ -258,29 +333,8 @@ async function selectStatusMappings(devStates, qaStates, options) {
|
|
|
258
333
|
choices: devStateChoices,
|
|
259
334
|
initial: devStateChoices.findIndex((c) => c.value === devDefaults.done),
|
|
260
335
|
});
|
|
261
|
-
// Testing uses qa team states (or dev team if qa not set)
|
|
262
|
-
const testingStateChoices = testingStates.map((s) => ({
|
|
263
|
-
title: `${s.name} (${s.type})`,
|
|
264
|
-
value: s.name,
|
|
265
|
-
}));
|
|
266
|
-
const testingChoices = [
|
|
267
|
-
{ title: "(None)", value: undefined },
|
|
268
|
-
...testingStateChoices,
|
|
269
|
-
];
|
|
270
|
-
const testingMessage = qaStates && qaStates.length > 0
|
|
271
|
-
? 'Select status for "Testing" (from QA team, for parent tasks):'
|
|
272
|
-
: 'Select status for "Testing" (optional, for parent tasks):';
|
|
273
|
-
const testingResponse = await prompts({
|
|
274
|
-
type: "select",
|
|
275
|
-
name: "testing",
|
|
276
|
-
message: testingMessage,
|
|
277
|
-
choices: testingChoices,
|
|
278
|
-
initial: testingDefaults.testing
|
|
279
|
-
? testingChoices.findIndex((c) => c.value === testingDefaults.testing)
|
|
280
|
-
: 0,
|
|
281
|
-
});
|
|
282
336
|
const blockedChoices = [
|
|
283
|
-
{ title: "(
|
|
337
|
+
{ title: "(Skip - no blocked status)", value: undefined },
|
|
284
338
|
...devStateChoices,
|
|
285
339
|
];
|
|
286
340
|
const blockedResponse = await prompts({
|
|
@@ -296,7 +350,7 @@ async function selectStatusMappings(devStates, qaStates, options) {
|
|
|
296
350
|
todo: todoResponse.todo || devDefaults.todo,
|
|
297
351
|
in_progress: inProgressResponse.in_progress || devDefaults.in_progress,
|
|
298
352
|
done: doneResponse.done || devDefaults.done,
|
|
299
|
-
testing:
|
|
353
|
+
testing: devDefaults.testing, // Will be overridden by dev_testing_status in LocalConfig
|
|
300
354
|
blocked: blockedResponse.blocked,
|
|
301
355
|
};
|
|
302
356
|
}
|
|
@@ -536,12 +590,9 @@ async function init() {
|
|
|
536
590
|
console.error("Error: No teams found in your Linear workspace.");
|
|
537
591
|
process.exit(1);
|
|
538
592
|
}
|
|
539
|
-
// Select
|
|
540
|
-
const
|
|
541
|
-
console.log(`
|
|
542
|
-
if (selectedTeams.length > 1) {
|
|
543
|
-
console.log(` Primary: ${primaryTeam.name}`);
|
|
544
|
-
}
|
|
593
|
+
// Select dev team (single selection)
|
|
594
|
+
const devTeam = await selectDevTeam(teams, options);
|
|
595
|
+
console.log(` Dev Team: ${devTeam.name}`);
|
|
545
596
|
// Fetch data from ALL teams (not just primary) to support cross-team operations
|
|
546
597
|
console.log(` Fetching data from ${teams.length} teams...`);
|
|
547
598
|
// Collect users from all teams, but labels only from primary team
|
|
@@ -563,8 +614,8 @@ async function init() {
|
|
|
563
614
|
allUsers.push(user);
|
|
564
615
|
}
|
|
565
616
|
}
|
|
566
|
-
// Labels: only from
|
|
567
|
-
if (team.id ===
|
|
617
|
+
// Labels: only from dev team
|
|
618
|
+
if (team.id === devTeam.id) {
|
|
568
619
|
const labelsData = await client.issueLabels({
|
|
569
620
|
filter: { team: { id: { eq: team.id } } },
|
|
570
621
|
});
|
|
@@ -589,7 +640,7 @@ async function init() {
|
|
|
589
640
|
}
|
|
590
641
|
teamStatesMap.set(team.id, teamStates);
|
|
591
642
|
}
|
|
592
|
-
catch
|
|
643
|
+
catch {
|
|
593
644
|
console.warn(` ⚠ Could not fetch data for team ${team.name}, skipping...`);
|
|
594
645
|
}
|
|
595
646
|
}
|
|
@@ -597,34 +648,34 @@ async function init() {
|
|
|
597
648
|
const labels = allLabels;
|
|
598
649
|
const states = allStates;
|
|
599
650
|
// Get team-specific states for status mapping
|
|
600
|
-
const
|
|
651
|
+
const devTeamStates = teamStatesMap.get(devTeam.id) || [];
|
|
601
652
|
console.log(` Users: ${users.length}`);
|
|
602
|
-
console.log(` Labels: ${labels.length} (from ${
|
|
653
|
+
console.log(` Labels: ${labels.length} (from ${devTeam.name})`);
|
|
603
654
|
console.log(` Workflow states: ${states.length}`);
|
|
604
|
-
// Get cycle from
|
|
605
|
-
const selectedTeam = await client.team(
|
|
655
|
+
// Get cycle from dev team (for current work tracking)
|
|
656
|
+
const selectedTeam = await client.team(devTeam.id);
|
|
606
657
|
const currentCycle = (await selectedTeam.activeCycle);
|
|
607
658
|
// User selections
|
|
608
659
|
const currentUser = await selectUser(users, options);
|
|
609
660
|
const defaultLabel = await selectLabelFilter(labels, options);
|
|
610
661
|
const statusSource = await selectStatusSource(options);
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const
|
|
662
|
+
// Status transitions for dev team (todo, in_progress, done, blocked)
|
|
663
|
+
const statusTransitions = await selectStatusMappings(devTeamStates, options);
|
|
664
|
+
// Dev team testing status (for strict_review mode)
|
|
665
|
+
const devTestingStatus = await selectDevTestingStatus(devTeamStates, options);
|
|
666
|
+
// Build preliminary teams config for selectQaPmTeams
|
|
667
|
+
const teamsConfig = buildTeamsConfig(teams);
|
|
668
|
+
// QA/PM teams selection (multiple, each with its own testing status)
|
|
669
|
+
const qaPmTeams = await selectQaPmTeams(teams, devTeam, teamStatesMap, teamsConfig, options);
|
|
670
|
+
// Completion mode selection
|
|
671
|
+
const completionMode = await selectCompletionMode(qaPmTeams.length > 0, options);
|
|
615
672
|
// Build config
|
|
616
673
|
const config = buildConfig(teams, users, labels, states, statusTransitions, currentCycle ?? undefined);
|
|
617
674
|
// Find keys
|
|
618
675
|
const currentUserKey = findUserKey(config.users, currentUser.id);
|
|
619
|
-
const
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
.filter((key) => key !== undefined);
|
|
623
|
-
const qaPmTeamKey = qaPmTeam
|
|
624
|
-
? findTeamKey(config.teams, qaPmTeam.id)
|
|
625
|
-
: undefined;
|
|
626
|
-
const localConfig = buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, undefined, // excludeLabels
|
|
627
|
-
statusSource, qaPmTeamKey);
|
|
676
|
+
const devTeamKey = findTeamKey(config.teams, devTeam.id);
|
|
677
|
+
const localConfig = buildLocalConfig(currentUserKey, devTeamKey, devTestingStatus, qaPmTeams, completionMode, defaultLabel, undefined, // excludeLabels
|
|
678
|
+
statusSource);
|
|
628
679
|
// Write config files
|
|
629
680
|
console.log("\n📝 Writing configuration files...");
|
|
630
681
|
await fs.mkdir(paths.baseDir, { recursive: true });
|
|
@@ -659,10 +710,12 @@ async function init() {
|
|
|
659
710
|
localConfig.current_user = existingLocal.current_user;
|
|
660
711
|
if (existingLocal.team)
|
|
661
712
|
localConfig.team = existingLocal.team;
|
|
662
|
-
if (existingLocal.
|
|
663
|
-
localConfig.
|
|
664
|
-
if (existingLocal.
|
|
665
|
-
localConfig.
|
|
713
|
+
if (existingLocal.dev_testing_status)
|
|
714
|
+
localConfig.dev_testing_status = existingLocal.dev_testing_status;
|
|
715
|
+
if (existingLocal.qa_pm_teams)
|
|
716
|
+
localConfig.qa_pm_teams = existingLocal.qa_pm_teams;
|
|
717
|
+
if (existingLocal.completion_mode)
|
|
718
|
+
localConfig.completion_mode = existingLocal.completion_mode;
|
|
666
719
|
if (existingLocal.label)
|
|
667
720
|
localConfig.label = existingLocal.label;
|
|
668
721
|
if (existingLocal.exclude_labels)
|
|
@@ -684,15 +737,19 @@ async function init() {
|
|
|
684
737
|
// Summary
|
|
685
738
|
console.log("\n✅ Initialization complete!\n");
|
|
686
739
|
console.log("Configuration summary:");
|
|
687
|
-
console.log(`
|
|
688
|
-
if (selectedTeams.length > 1) {
|
|
689
|
-
console.log(` Primary team: ${primaryTeam.name}`);
|
|
690
|
-
}
|
|
740
|
+
console.log(` Dev Team: ${devTeam.name}`);
|
|
691
741
|
console.log(` User: ${currentUser.displayName || currentUser.name} (${currentUser.email})`);
|
|
692
742
|
console.log(` Label filter: ${defaultLabel || "(none)"}`);
|
|
693
743
|
console.log(` Status source: ${statusSource === "local" ? "local (use 'sync --update' to push)" : "remote (immediate sync)"}`);
|
|
694
|
-
|
|
695
|
-
|
|
744
|
+
console.log(` Completion mode: ${completionMode}`);
|
|
745
|
+
if (devTestingStatus) {
|
|
746
|
+
console.log(` Dev testing status: ${devTestingStatus}`);
|
|
747
|
+
}
|
|
748
|
+
if (qaPmTeams.length > 0) {
|
|
749
|
+
console.log(` QA/PM teams:`);
|
|
750
|
+
for (const qaPmTeam of qaPmTeams) {
|
|
751
|
+
console.log(` - ${qaPmTeam.team}: ${qaPmTeam.testing_status}`);
|
|
752
|
+
}
|
|
696
753
|
}
|
|
697
754
|
console.log(` (Use 'ttt config filters' to set excluded labels/users)`);
|
|
698
755
|
if (currentCycle) {
|
|
@@ -702,9 +759,6 @@ async function init() {
|
|
|
702
759
|
console.log(` Todo: ${statusTransitions.todo}`);
|
|
703
760
|
console.log(` In Progress: ${statusTransitions.in_progress}`);
|
|
704
761
|
console.log(` Done: ${statusTransitions.done}`);
|
|
705
|
-
if (statusTransitions.testing) {
|
|
706
|
-
console.log(` Testing: ${statusTransitions.testing}`);
|
|
707
|
-
}
|
|
708
762
|
if (statusTransitions.blocked) {
|
|
709
763
|
console.log(` Blocked: ${statusTransitions.blocked}`);
|
|
710
764
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Config, LocalConfig, StatusTransitions, TeamConfig, UserConfig, LabelConfig } from "../utils.js";
|
|
1
|
+
import type { CompletionMode, Config, LocalConfig, QaPmTeamConfig, StatusTransitions, TeamConfig, UserConfig, LabelConfig } from "../utils.js";
|
|
2
2
|
export interface LinearTeam {
|
|
3
3
|
id: string;
|
|
4
4
|
name: string;
|
|
@@ -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,
|
|
41
|
+
export declare function buildLocalConfig(currentUserKey: string, devTeamKey: string, devTestingStatus?: string, qaPmTeams?: QaPmTeamConfig[], completionMode?: CompletionMode, defaultLabel?: string, excludeLabels?: string[], statusSource?: "remote" | "local"): LocalConfig;
|
|
@@ -108,12 +108,13 @@ export function findTeamKey(teamsConfig, teamId) {
|
|
|
108
108
|
return (Object.entries(teamsConfig).find(([_, t]) => t.id === teamId)?.[0] ||
|
|
109
109
|
Object.keys(teamsConfig)[0]);
|
|
110
110
|
}
|
|
111
|
-
export function buildLocalConfig(currentUserKey,
|
|
111
|
+
export function buildLocalConfig(currentUserKey, devTeamKey, devTestingStatus, qaPmTeams, completionMode, defaultLabel, excludeLabels, statusSource) {
|
|
112
112
|
return {
|
|
113
113
|
current_user: currentUserKey,
|
|
114
|
-
team:
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
team: devTeamKey,
|
|
115
|
+
dev_testing_status: devTestingStatus,
|
|
116
|
+
qa_pm_teams: qaPmTeams && qaPmTeams.length > 0 ? qaPmTeams : undefined,
|
|
117
|
+
completion_mode: completionMode,
|
|
117
118
|
label: defaultLabel,
|
|
118
119
|
exclude_labels: excludeLabels && excludeLabels.length > 0 ? excludeLabels : undefined,
|
|
119
120
|
status_source: statusSource,
|
|
@@ -24,12 +24,13 @@ export function extractLinearImageUrls(text) {
|
|
|
24
24
|
/(https?:\/\/uploads\.linear\.app\/[^\s)>\]]+)/g, // Plain Linear upload URLs
|
|
25
25
|
];
|
|
26
26
|
for (const pattern of patterns) {
|
|
27
|
-
let match;
|
|
28
|
-
while (
|
|
27
|
+
let match = pattern.exec(text);
|
|
28
|
+
while (match) {
|
|
29
29
|
const url = match[1];
|
|
30
30
|
if (isLinearImageUrl(url) && !urls.includes(url)) {
|
|
31
31
|
urls.push(url);
|
|
32
32
|
}
|
|
33
|
+
match = pattern.exec(text);
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
return urls;
|
|
@@ -66,7 +67,7 @@ export async function downloadLinearFile(url, issueId, attachmentId, outputDir)
|
|
|
66
67
|
// Linear files require authentication
|
|
67
68
|
const headers = {};
|
|
68
69
|
if (process.env.LINEAR_API_KEY) {
|
|
69
|
-
headers
|
|
70
|
+
headers.Authorization = process.env.LINEAR_API_KEY;
|
|
70
71
|
}
|
|
71
72
|
const response = await fetch(url, { headers });
|
|
72
73
|
if (!response.ok) {
|
package/dist/scripts/utils.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface StatusTransitions {
|
|
|
35
35
|
testing?: string;
|
|
36
36
|
blocked?: string;
|
|
37
37
|
}
|
|
38
|
+
export interface QaPmTeamConfig {
|
|
39
|
+
team: string;
|
|
40
|
+
testing_status: string;
|
|
41
|
+
}
|
|
42
|
+
export type CompletionMode = "simple" | "strict_review" | "upstream_strict" | "upstream_not_strict";
|
|
38
43
|
export interface Config {
|
|
39
44
|
teams: Record<string, TeamConfig>;
|
|
40
45
|
users: Record<string, UserConfig>;
|
|
@@ -94,11 +99,14 @@ export interface CycleData {
|
|
|
94
99
|
export interface LocalConfig {
|
|
95
100
|
current_user: string;
|
|
96
101
|
team: string;
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
dev_testing_status?: string;
|
|
103
|
+
qa_pm_teams?: QaPmTeamConfig[];
|
|
104
|
+
completion_mode?: CompletionMode;
|
|
99
105
|
exclude_labels?: string[];
|
|
100
106
|
label?: string;
|
|
101
107
|
status_source?: "remote" | "local";
|
|
108
|
+
teams?: string[];
|
|
109
|
+
qa_pm_team?: string;
|
|
102
110
|
}
|
|
103
111
|
export declare function fileExists(filePath: string): Promise<boolean>;
|
|
104
112
|
export declare function loadConfig(): Promise<Config>;
|