ai-git-tools 2.1.13 → 2.1.14
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 +33 -0
- package/bin/cli.js +11 -0
- package/package.json +1 -1
- package/src/commands/redmine-subtasks.js +146 -0
- package/src/core/ai-client.js +1 -1
- package/src/redmine/issue-analyzer.js +3 -2
- package/src/redmine/redmine-client.js +46 -2
- package/src/redmine/redmine-formatters.js +2 -1
- package/src/redmine/subtask-analyzer.js +238 -0
- package/src/redmine/subtask-formatters.js +156 -0
- package/src/redmine/subtask-sync.js +208 -0
package/README.md
CHANGED
|
@@ -401,6 +401,39 @@ AI 產出的 notes 會固定整理成以下架構:
|
|
|
401
401
|
|
|
402
402
|
所有 Copilot AI 回應會在程式內統一轉換為台灣繁體中文,包含 Redmine notes、需求說明、實作內容、行為規則、影響範圍與 Mermaid 文字;不依賴模型自行遵守語言設定。
|
|
403
403
|
|
|
404
|
+
### `ai-git-tools redmine-subtasks`
|
|
405
|
+
|
|
406
|
+
讀取一個 Redmine 主 Issue,依功能或頁面整理成少量、可獨立交付的大項子任務,並在人工審核後透過 Redmine API 建立 children。以 Issue #18793 為例:
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
# 只讀取與分析,不會建立子任務
|
|
410
|
+
ai-git-tools redmine-subtasks \
|
|
411
|
+
--issue 18793 \
|
|
412
|
+
--preview \
|
|
413
|
+
--output redmine-subtasks.json
|
|
414
|
+
|
|
415
|
+
# 確認草稿中的 title 與 content 後才建立子任務
|
|
416
|
+
ai-git-tools redmine-subtasks --apply --from redmine-subtasks.json
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
預設會產生 2~8 個功能/頁面層級的大項;`--max-subtasks <number>` 可設定 1~12 的上限。工具不會把單一 endpoint、component、function 或測試案例拆成獨立 Issue。AI 產生的每個子任務會整理目的、工作範圍、實作重點、行為規則、驗收條件、依賴與待確認事項;`title` 會寫入 Redmine `subject`,完整 `content` 會寫入 `description`。
|
|
420
|
+
|
|
421
|
+
需求能推導出使用流程時,content 會包含 Redmine Mermaid macro:
|
|
422
|
+
|
|
423
|
+
```text
|
|
424
|
+
{{mermaid
|
|
425
|
+
flowchart TD
|
|
426
|
+
A[使用者操作] --> B[系統處理]
|
|
427
|
+
B --> C[顯示結果]
|
|
428
|
+
}}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
子任務涉及頁面但主 Issue 沒有明確畫面設計時,content 會附上純文字 wireframe,只描述資訊區塊、主要操作與已知狀態,不猜測顏色、尺寸或像素。沒有可靠流程或畫面需求時,對應區段會省略,未決事項會保留為待確認項目。
|
|
432
|
+
|
|
433
|
+
apply 前會重新讀取主 Issue。若 subject、description、project 或 tracker 已變更,預設會停止建立;確認變更仍可套用時才使用 `--force`。既有子任務若有相同的 managed key 或完全相同的 title,會略過以避免重複建立。多筆建立採逐筆處理,部分成功不會回滾已建立的子任務。
|
|
434
|
+
|
|
435
|
+
此命令只建立主 Issue 底下的子任務,不會修改主 Issue 的 status、description、notes、done ratio 或完成欄位。API key 沿用上方的 `REDMINE_API_KEY` 環境變數,不會放入 CLI 參數、草稿或輸出內容;AI 產出的拆分結果仍必須由使用者審核。
|
|
436
|
+
|
|
404
437
|
## ⚙️ 配置
|
|
405
438
|
|
|
406
439
|
配置檔案範例(\`.ai-git-config.js\`):
|
package/bin/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ import { initCommand } from '../src/commands/init.js';
|
|
|
17
17
|
import { usageCommand } from '../src/commands/usage.js';
|
|
18
18
|
import { modelInfoCommand } from '../src/commands/model-info.js';
|
|
19
19
|
import { redmineUpdateCommand } from '../src/commands/redmine-update.js';
|
|
20
|
+
import { redmineSubtasksCommand } from '../src/commands/redmine-subtasks.js';
|
|
20
21
|
import { registerCommand } from '../src/utils/cli-helpers.js';
|
|
21
22
|
|
|
22
23
|
// 讀取 package.json 獲取版本號
|
|
@@ -102,4 +103,14 @@ registerCommand(program, 'redmine-update', '分析並更新 Redmine Issue 的開
|
|
|
102
103
|
{ flags: '--force', description: '強制略過 Issue 狀態或內容衝突檢查' },
|
|
103
104
|
], redmineUpdateCommand);
|
|
104
105
|
|
|
106
|
+
registerCommand(program, 'redmine-subtasks', '分析主 Issue 並建立功能/頁面子任務', [
|
|
107
|
+
{ flags: '--issue <id>', description: '指定 Redmine 主 Issue ID' },
|
|
108
|
+
{ flags: '--max-subtasks <number>', description: '子任務大項上限(預設:8)' },
|
|
109
|
+
{ flags: '--preview', description: '產生並顯示拆分預覽,不修改 Redmine' },
|
|
110
|
+
{ flags: '--output <file>', description: '保存 preview 草稿 JSON' },
|
|
111
|
+
{ flags: '--apply', description: '套用已審核的子任務草稿' },
|
|
112
|
+
{ flags: '--from <file>', description: '指定要套用的子任務草稿 JSON' },
|
|
113
|
+
{ flags: '--force', description: '強制略過主 Issue 內容衝突檢查' },
|
|
114
|
+
], redmineSubtasksCommand);
|
|
115
|
+
|
|
105
116
|
program.parse();
|
package/package.json
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { loadConfig } from '../core/config-loader.js';
|
|
3
|
+
import { RedmineClient } from '../redmine/redmine-client.js';
|
|
4
|
+
import { buildRedmineConfig } from '../redmine/config.js';
|
|
5
|
+
import {
|
|
6
|
+
applySubtaskDraft,
|
|
7
|
+
generateSubtaskDraft,
|
|
8
|
+
parseSubtaskDraft,
|
|
9
|
+
serializeSubtaskDraft,
|
|
10
|
+
} from '../redmine/subtask-sync.js';
|
|
11
|
+
import { formatSubtaskPreview } from '../redmine/subtask-formatters.js';
|
|
12
|
+
import { Logger } from '../utils/logger.js';
|
|
13
|
+
|
|
14
|
+
const logger = new Logger();
|
|
15
|
+
|
|
16
|
+
function normalizeIssueOptions(issue) {
|
|
17
|
+
return Array.isArray(issue) ? issue : issue === undefined ? [] : [issue];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 驗證 Redmine 子任務命令參數
|
|
22
|
+
* @param {object} options
|
|
23
|
+
*/
|
|
24
|
+
export function validateRedmineSubtaskOptions(options = {}) {
|
|
25
|
+
if (options.apply && options.preview) throw new Error('--apply 與 --preview 不可同時使用');
|
|
26
|
+
if (options.apply && !options.from) throw new Error('--apply 必須搭配 --from <file> 使用');
|
|
27
|
+
if (options.from && !options.apply) throw new Error('--from 只能搭配 --apply 使用');
|
|
28
|
+
if (options.output && !options.preview) throw new Error('--output 只能搭配 --preview 使用');
|
|
29
|
+
if (options.force && !options.apply) throw new Error('--force 只能搭配 --apply 使用');
|
|
30
|
+
if (!options.apply && !options.preview) {
|
|
31
|
+
throw new Error('請使用 --preview 產生草稿,確認後再使用 --apply --from <file>');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const issueIds = normalizeIssueOptions(options.issue);
|
|
35
|
+
if (!options.apply && issueIds.length === 0) throw new Error('preview 模式需要 --issue <id>');
|
|
36
|
+
const invalidIssueIds = issueIds.filter(id => !/^[1-9]\d*$/.test(String(id)));
|
|
37
|
+
if (invalidIssueIds.length > 0) throw new Error(`Issue ID 必須是正整數:${invalidIssueIds.join('、')}`);
|
|
38
|
+
|
|
39
|
+
if (options.maxSubtasks !== undefined) {
|
|
40
|
+
const maxSubtasks = Number(options.maxSubtasks);
|
|
41
|
+
if (!Number.isInteger(maxSubtasks) || maxSubtasks < 1 || maxSubtasks > 12) {
|
|
42
|
+
throw new Error('maxSubtasks 必須是 1 到 12 的整數');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 格式化子任務處理進度
|
|
49
|
+
* @param {object} progress
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function formatSubtaskProgress(progress = {}) {
|
|
53
|
+
switch (progress.phase) {
|
|
54
|
+
case 'read-parent':
|
|
55
|
+
return `讀取 Redmine Issue #${progress.parentIssueId}...`;
|
|
56
|
+
case 'parent-read':
|
|
57
|
+
return `主 Issue #${progress.parentIssueId} 已讀取:${progress.subject || '無標題'}`;
|
|
58
|
+
case 'analyze-subtasks':
|
|
59
|
+
return `分析 Issue #${progress.parentIssueId} 的功能/頁面大項...`;
|
|
60
|
+
case 'subtasks-analyzed':
|
|
61
|
+
return `已產生 ${progress.count} 個子任務候選`;
|
|
62
|
+
case 'create-child':
|
|
63
|
+
return `[${progress.index}/${progress.total}] 建立子任務:${progress.title || '無標題'}`;
|
|
64
|
+
case 'preview':
|
|
65
|
+
return '預覽完成,尚未修改 Redmine';
|
|
66
|
+
default:
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function printProgress(progress) {
|
|
72
|
+
const message = formatSubtaskProgress(progress);
|
|
73
|
+
if (!message) return;
|
|
74
|
+
if (progress.phase === 'parent-read' || progress.phase === 'subtasks-analyzed' || progress.phase === 'preview') {
|
|
75
|
+
logger.success(message);
|
|
76
|
+
} else {
|
|
77
|
+
logger.step(message);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function printApplyResults(results) {
|
|
82
|
+
for (const result of results) {
|
|
83
|
+
if (result.created) {
|
|
84
|
+
console.log(`✅ 子任務「${result.title}」已建立:#${result.childId || '—'}`);
|
|
85
|
+
} else if (result.skipped) {
|
|
86
|
+
console.log(`⚠️ 子任務「${result.title}」略過:已有相同子任務`);
|
|
87
|
+
} else if (result.blocked) {
|
|
88
|
+
console.log(`❌ 主 Issue #${result.parentIssueId} 已變更,略過全部建立`);
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`❌ 子任務「${result.title}」建立失敗:${result.error}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hasSubtaskFailures(results = []) {
|
|
96
|
+
return results.some(result => result.blocked || result.created === false);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 執行 Redmine 子任務拆分 preview 或 apply
|
|
101
|
+
* @param {object} options
|
|
102
|
+
*/
|
|
103
|
+
export async function redmineSubtasksCommand(options = {}) {
|
|
104
|
+
validateRedmineSubtaskOptions(options);
|
|
105
|
+
logger.header('Redmine Issue 子任務拆分');
|
|
106
|
+
const config = await loadConfig();
|
|
107
|
+
const redmineConfig = buildRedmineConfig(config, process.env);
|
|
108
|
+
const client = new RedmineClient(redmineConfig);
|
|
109
|
+
|
|
110
|
+
if (options.apply) {
|
|
111
|
+
logger.step(`讀取已審核草稿:${options.from}`);
|
|
112
|
+
const draft = parseSubtaskDraft(readFileSync(options.from, 'utf-8'));
|
|
113
|
+
const results = await applySubtaskDraft({
|
|
114
|
+
draft,
|
|
115
|
+
client,
|
|
116
|
+
force: options.force,
|
|
117
|
+
onProgress: printProgress,
|
|
118
|
+
});
|
|
119
|
+
printApplyResults(results);
|
|
120
|
+
if (hasSubtaskFailures(results)) {
|
|
121
|
+
throw new Error('部分子任務建立失敗或主 Issue 發生衝突,請處理後重新套用');
|
|
122
|
+
}
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const [parentIssueId] = normalizeIssueOptions(options.issue);
|
|
127
|
+
const draft = await generateSubtaskDraft({
|
|
128
|
+
parentIssueId,
|
|
129
|
+
client,
|
|
130
|
+
maxSubtasks: options.maxSubtasks === undefined ? 8 : Number(options.maxSubtasks),
|
|
131
|
+
model: config.ai.model,
|
|
132
|
+
maxRetries: config.ai.maxRetries,
|
|
133
|
+
onProgress: printProgress,
|
|
134
|
+
});
|
|
135
|
+
console.log(formatSubtaskPreview({ parent: draft.parent, candidates: draft.subtasks }));
|
|
136
|
+
printProgress({ phase: 'preview' });
|
|
137
|
+
|
|
138
|
+
if (options.output) {
|
|
139
|
+
writeFileSync(options.output, serializeSubtaskDraft(draft), 'utf-8');
|
|
140
|
+
console.log(`預覽草稿已保存:${options.output}`);
|
|
141
|
+
console.log(`請使用 --apply --from ${options.output} 套用。`);
|
|
142
|
+
}
|
|
143
|
+
return draft;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { hasSubtaskFailures };
|
package/src/core/ai-client.js
CHANGED
|
@@ -25,7 +25,7 @@ function normalizeFlowcharts(value) {
|
|
|
25
25
|
: [];
|
|
26
26
|
|
|
27
27
|
const usedIds = new Set();
|
|
28
|
-
return flowcharts
|
|
28
|
+
return flowcharts
|
|
29
29
|
.filter(flowchart => flowchart && typeof flowchart === 'object')
|
|
30
30
|
.map((flowchart, index) => {
|
|
31
31
|
const title = typeof flowchart.title === 'string' ? flowchart.title.trim() : '';
|
|
@@ -48,7 +48,8 @@ function normalizeFlowcharts(value) {
|
|
|
48
48
|
evidence: asStringArray(flowchart.evidence),
|
|
49
49
|
};
|
|
50
50
|
})
|
|
51
|
-
.filter(flowchart => flowchart.source)
|
|
51
|
+
.filter(flowchart => flowchart.source)
|
|
52
|
+
.slice(0, 1);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
function asStringArray(value) {
|
|
@@ -28,16 +28,30 @@ export class RedmineAPIError extends Error {
|
|
|
28
28
|
* @returns {object}
|
|
29
29
|
*/
|
|
30
30
|
export function normalizeIssue(rawIssue = {}) {
|
|
31
|
+
const project = rawIssue.project || null;
|
|
32
|
+
const tracker = rawIssue.tracker || null;
|
|
33
|
+
const children = rawIssue.children || rawIssue.child_issues || [];
|
|
34
|
+
|
|
31
35
|
return {
|
|
32
36
|
id: rawIssue.id,
|
|
33
37
|
subject: rawIssue.subject || '',
|
|
34
38
|
description: rawIssue.description || '',
|
|
35
39
|
status: rawIssue.status || null,
|
|
36
|
-
tracker
|
|
40
|
+
tracker,
|
|
41
|
+
trackerId: rawIssue.tracker_id ?? tracker?.id ?? null,
|
|
37
42
|
parentId: rawIssue.parent_issue_id || rawIssue.parent?.id || null,
|
|
38
43
|
customFields: rawIssue.custom_fields || rawIssue.customFields || [],
|
|
39
44
|
allowedStatuses: rawIssue.allowed_statuses || rawIssue.allowedStatuses || [],
|
|
40
|
-
project
|
|
45
|
+
project,
|
|
46
|
+
projectId: rawIssue.project_id ?? project?.id ?? null,
|
|
47
|
+
children: Array.isArray(children)
|
|
48
|
+
? children.map(child => ({
|
|
49
|
+
id: child.id,
|
|
50
|
+
subject: child.subject || '',
|
|
51
|
+
description: child.description || '',
|
|
52
|
+
}))
|
|
53
|
+
: [],
|
|
54
|
+
relations: Array.isArray(rawIssue.relations) ? rawIssue.relations : [],
|
|
41
55
|
assignedTo: rawIssue.assigned_to || rawIssue.assignedTo || null,
|
|
42
56
|
raw: rawIssue,
|
|
43
57
|
};
|
|
@@ -121,6 +135,36 @@ export class RedmineClient {
|
|
|
121
135
|
return normalizeIssue(data.issue || data);
|
|
122
136
|
}
|
|
123
137
|
|
|
138
|
+
/**
|
|
139
|
+
* @param {{projectId: number|string, trackerId: number|string, subject: string, description: string, parentIssueId: number|string}} input
|
|
140
|
+
* @returns {Promise<object>}
|
|
141
|
+
*/
|
|
142
|
+
async createIssue({ projectId, trackerId, subject, description, parentIssueId }) {
|
|
143
|
+
if (projectId === undefined || projectId === null || trackerId === undefined || trackerId === null) {
|
|
144
|
+
throw new Error('建立 Redmine 子任務需要 projectId 與 trackerId');
|
|
145
|
+
}
|
|
146
|
+
if (!subject?.trim() || !description?.trim()) {
|
|
147
|
+
throw new Error('建立 Redmine 子任務需要 subject 與 description');
|
|
148
|
+
}
|
|
149
|
+
if (parentIssueId === undefined || parentIssueId === null) {
|
|
150
|
+
throw new Error('建立 Redmine 子任務需要 parentIssueId');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const data = await this.request('/issues.json', {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
json: {
|
|
156
|
+
issue: {
|
|
157
|
+
project_id: projectId,
|
|
158
|
+
tracker_id: trackerId,
|
|
159
|
+
subject,
|
|
160
|
+
description,
|
|
161
|
+
parent_issue_id: parentIssueId,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
return data.issue || data;
|
|
166
|
+
}
|
|
167
|
+
|
|
124
168
|
/**
|
|
125
169
|
* @returns {Promise<RedmineStatus[]>}
|
|
126
170
|
*/
|
|
@@ -41,7 +41,7 @@ function formatBehaviorRules(rules = []) {
|
|
|
41
41
|
function formatFlowcharts(flowcharts = []) {
|
|
42
42
|
if (!Array.isArray(flowcharts) || flowcharts.length === 0) return '';
|
|
43
43
|
|
|
44
|
-
return flowcharts
|
|
44
|
+
return flowcharts
|
|
45
45
|
.map((flowchart, index) => {
|
|
46
46
|
const mermaid = formatMermaid(flowchart?.source);
|
|
47
47
|
if (!mermaid) return '';
|
|
@@ -50,6 +50,7 @@ function formatFlowcharts(flowcharts = []) {
|
|
|
50
50
|
return `### 流程圖:${title}\n\n<!-- flowchart-id: ${id} -->\n${mermaid}\n`;
|
|
51
51
|
})
|
|
52
52
|
.filter(Boolean)
|
|
53
|
+
.slice(0, 1)
|
|
53
54
|
.join('\n');
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { AIClient } from '../core/ai-client.js';
|
|
2
|
+
import { normalizeAnalysisLanguage } from '../utils/traditional-chinese.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_SUBTASKS = 8;
|
|
5
|
+
const MIN_MAX_SUBTASKS = 1;
|
|
6
|
+
const MAX_MAX_SUBTASKS = 12;
|
|
7
|
+
|
|
8
|
+
function assertMaxSubtasks(maxSubtasks = DEFAULT_MAX_SUBTASKS) {
|
|
9
|
+
if (!Number.isInteger(maxSubtasks) || maxSubtasks < MIN_MAX_SUBTASKS || maxSubtasks > MAX_MAX_SUBTASKS) {
|
|
10
|
+
throw new Error(`maxSubtasks 必須介於 ${MIN_MAX_SUBTASKS} 到 ${MAX_MAX_SUBTASKS} 之間`);
|
|
11
|
+
}
|
|
12
|
+
return maxSubtasks;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function asString(value) {
|
|
16
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function asStringArray(value) {
|
|
20
|
+
return Array.isArray(value)
|
|
21
|
+
? value.filter(item => typeof item === 'string').map(item => item.trim()).filter(Boolean)
|
|
22
|
+
: [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function asBehaviorRules(value) {
|
|
26
|
+
return Array.isArray(value)
|
|
27
|
+
? value
|
|
28
|
+
.filter(item => item && typeof item === 'object')
|
|
29
|
+
.map(item => ({ scenario: asString(item.scenario), behavior: asString(item.behavior) }))
|
|
30
|
+
.filter(item => item.scenario && item.behavior)
|
|
31
|
+
: [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function slugify(value, fallback) {
|
|
35
|
+
return (asString(value) || fallback)
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
38
|
+
.replace(/^-+|-+$/g, '') || fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeFlowcharts(value) {
|
|
42
|
+
const flowcharts = Array.isArray(value)
|
|
43
|
+
? value
|
|
44
|
+
: value && typeof value === 'object'
|
|
45
|
+
? [value]
|
|
46
|
+
: [];
|
|
47
|
+
const usedIds = new Set();
|
|
48
|
+
|
|
49
|
+
return flowcharts
|
|
50
|
+
.filter(flowchart => flowchart && typeof flowchart === 'object')
|
|
51
|
+
.map((flowchart, index) => {
|
|
52
|
+
const fallback = `flowchart-${index + 1}`;
|
|
53
|
+
const id = slugify(flowchart.id || flowchart.title, fallback);
|
|
54
|
+
let uniqueId = id;
|
|
55
|
+
let suffix = index + 1;
|
|
56
|
+
while (usedIds.has(uniqueId)) uniqueId = `${id}-${suffix++}`;
|
|
57
|
+
usedIds.add(uniqueId);
|
|
58
|
+
return {
|
|
59
|
+
id: uniqueId,
|
|
60
|
+
title: asString(flowchart.title),
|
|
61
|
+
source: asString(flowchart.source || flowchart.mermaid),
|
|
62
|
+
evidence: asStringArray(flowchart.evidence),
|
|
63
|
+
};
|
|
64
|
+
})
|
|
65
|
+
.filter(flowchart => flowchart.source);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeSubtask(rawSubtask, index) {
|
|
69
|
+
const rawScope = rawSubtask.scope && typeof rawSubtask.scope === 'object' ? rawSubtask.scope : {};
|
|
70
|
+
return {
|
|
71
|
+
key: slugify(rawSubtask.key || rawSubtask.title, `subtask-${index + 1}`),
|
|
72
|
+
title: asString(rawSubtask.title),
|
|
73
|
+
category: rawSubtask.category === 'page' ? 'page' : rawSubtask.category === 'feature' ? 'feature' : '',
|
|
74
|
+
granularity: asString(rawSubtask.granularity || rawSubtask.level || 'large').toLowerCase(),
|
|
75
|
+
purpose: asString(rawSubtask.purpose),
|
|
76
|
+
inScope: asStringArray(rawSubtask.inScope || rawScope.inScope || rawScope.in),
|
|
77
|
+
outOfScope: asStringArray(rawSubtask.outOfScope || rawScope.outOfScope || rawScope.out),
|
|
78
|
+
implementationDetails: asStringArray(rawSubtask.implementationDetails),
|
|
79
|
+
behaviorRules: asBehaviorRules(rawSubtask.behaviorRules),
|
|
80
|
+
acceptanceCriteria: asStringArray(rawSubtask.acceptanceCriteria),
|
|
81
|
+
dependsOnKeys: asStringArray(rawSubtask.dependsOnKeys || rawSubtask.dependencies),
|
|
82
|
+
flowcharts: normalizeFlowcharts(rawSubtask.flowcharts || rawSubtask.flowchart),
|
|
83
|
+
wireframe: asString(rawSubtask.wireframe),
|
|
84
|
+
unresolvedItems: asStringArray(rawSubtask.unresolvedItems),
|
|
85
|
+
evidence: asStringArray(rawSubtask.evidence),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateSubtasks(result, parentIssueId, maxSubtasks) {
|
|
90
|
+
const expectedParentId = Number(parentIssueId);
|
|
91
|
+
if (result.parentIssueId === undefined || result.parentIssueId === null) {
|
|
92
|
+
throw new Error('AI 回應缺少 parentIssueId');
|
|
93
|
+
}
|
|
94
|
+
const actualParentId = Number(result.parentIssueId);
|
|
95
|
+
if (!Number.isInteger(expectedParentId) || actualParentId !== expectedParentId) {
|
|
96
|
+
throw new Error(`AI 回應的 parent Issue ID 不符合指定的 #${parentIssueId}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!Array.isArray(result.subtasks) || result.subtasks.length === 0) {
|
|
100
|
+
throw new Error('AI 沒有產生任何有效的子任務');
|
|
101
|
+
}
|
|
102
|
+
if (result.subtasks.length > maxSubtasks) {
|
|
103
|
+
throw new Error(`子任務數量超過 maxSubtasks 上限 ${maxSubtasks}`);
|
|
104
|
+
}
|
|
105
|
+
if (result.isIndivisible && result.subtasks.length !== 1) {
|
|
106
|
+
throw new Error('isIndivisible 為 true 時子任務數量必須恰好為 1');
|
|
107
|
+
}
|
|
108
|
+
if (result.subtasks.length === 1 && !result.isIndivisible) {
|
|
109
|
+
throw new Error('只有不可再拆的需求才能只產生 1 個子任務,請提供 indivisibleReason');
|
|
110
|
+
}
|
|
111
|
+
if (result.subtasks.length === 1 && !asString(result.indivisibleReason)) {
|
|
112
|
+
throw new Error('不可再拆的需求必須提供 indivisibleReason');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const keys = new Set();
|
|
116
|
+
const titles = new Set();
|
|
117
|
+
const sourceKeys = new Set();
|
|
118
|
+
for (const rawSubtask of result.subtasks) {
|
|
119
|
+
const sourceKey = asString(rawSubtask?.key).toLocaleLowerCase();
|
|
120
|
+
if (sourceKey && sourceKeys.has(sourceKey)) throw new Error(`子任務 key 重複:${sourceKey}`);
|
|
121
|
+
if (sourceKey) sourceKeys.add(sourceKey);
|
|
122
|
+
}
|
|
123
|
+
const subtasks = result.subtasks.map((rawSubtask, index) => normalizeSubtask(rawSubtask || {}, index));
|
|
124
|
+
for (const subtask of subtasks) {
|
|
125
|
+
const normalizedTitle = subtask.title.toLocaleLowerCase();
|
|
126
|
+
if (!subtask.title) throw new Error('子任務 title 不可為空');
|
|
127
|
+
if (titles.has(normalizedTitle)) throw new Error(`子任務 title 重複:${subtask.title}`);
|
|
128
|
+
if (keys.has(subtask.key)) throw new Error(`子任務 key 重複:${subtask.key}`);
|
|
129
|
+
if (!['feature', 'page'].includes(subtask.category)) {
|
|
130
|
+
throw new Error(`子任務 ${subtask.title} 必須是 feature 或 page category`);
|
|
131
|
+
}
|
|
132
|
+
if (subtask.granularity !== 'large') {
|
|
133
|
+
throw new Error(`子任務 ${subtask.title} 必須是大項,禁止 ${subtask.granularity} microtask`);
|
|
134
|
+
}
|
|
135
|
+
keys.add(subtask.key);
|
|
136
|
+
titles.add(normalizedTitle);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const subtask of subtasks) {
|
|
140
|
+
for (const dependency of subtask.dependsOnKeys) {
|
|
141
|
+
if (!keys.has(dependency)) throw new Error(`子任務 ${subtask.title} 使用未知 dependency:${dependency}`);
|
|
142
|
+
if (dependency === subtask.key) throw new Error(`子任務 ${subtask.title} 不可依賴自己`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
parentIssueId: expectedParentId,
|
|
148
|
+
isIndivisible: Boolean(result.isIndivisible),
|
|
149
|
+
indivisibleReason: asString(result.indivisibleReason),
|
|
150
|
+
unresolvedItems: asStringArray(result.unresolvedItems),
|
|
151
|
+
subtasks,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 建立主 Issue 子任務拆分 prompt
|
|
157
|
+
* @param {{issue: object, maxSubtasks?: number}} input
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
export function buildSubtaskAnalysisPrompt({ issue, maxSubtasks = DEFAULT_MAX_SUBTASKS }) {
|
|
161
|
+
assertMaxSubtasks(maxSubtasks);
|
|
162
|
+
const issuePromptData = { ...issue };
|
|
163
|
+
delete issuePromptData.raw;
|
|
164
|
+
return `你是資深產品分析師與軟體工程師,請將指定的 Redmine 主 Issue #${issue.id} 整理成少量、可獨立交付的功能或頁面大項子任務。
|
|
165
|
+
|
|
166
|
+
重要規則:
|
|
167
|
+
- 只根據主 Issue、既有子任務與 relations 進行需求消化,不得捏造未出現的需求。
|
|
168
|
+
- 所有自然語言欄位必須使用台灣繁體中文。
|
|
169
|
+
- 只拆分功能或頁面大項,不得把單一 endpoint、API 參數、component、function 或 test 拆成獨立子任務。
|
|
170
|
+
- 每個子任務必須是 granularity: large,category 只能是 feature 或 page。
|
|
171
|
+
- 預設產生 2 到 8 個子任務;只有一個不可再合理分離的交付範圍時,才能產生 1 個並填寫 isIndivisible 與 indivisibleReason。
|
|
172
|
+
- 流程能由需求明確推導時才產生 Mermaid;頁面沒有明確設計時才產生純文字 wireframe,不得猜測顏色、尺寸或像素。
|
|
173
|
+
- 不確定的權限、狀態、API 契約或畫面決策必須列入 unresolvedItems。
|
|
174
|
+
- 每個 evidence 必須能在輸入資料中找到對應文字;不要產生 API、角色或狀態的無證據敘述。
|
|
175
|
+
|
|
176
|
+
## 拆分限制
|
|
177
|
+
${JSON.stringify({ maxSubtasks }, null, 2)}
|
|
178
|
+
|
|
179
|
+
## 主 Issue
|
|
180
|
+
${JSON.stringify(issuePromptData, null, 2)}
|
|
181
|
+
|
|
182
|
+
請只回傳 JSON,不要使用 Markdown code fence:
|
|
183
|
+
{
|
|
184
|
+
"parentIssueId": ${issue.id},
|
|
185
|
+
"isIndivisible": false,
|
|
186
|
+
"indivisibleReason": "",
|
|
187
|
+
"unresolvedItems": [],
|
|
188
|
+
"subtasks": [{
|
|
189
|
+
"key": "stable-feature-key",
|
|
190
|
+
"title": "功能或頁面大項標題",
|
|
191
|
+
"category": "feature",
|
|
192
|
+
"granularity": "large",
|
|
193
|
+
"purpose": "這個子任務要交付什麼",
|
|
194
|
+
"inScope": [],
|
|
195
|
+
"outOfScope": [],
|
|
196
|
+
"implementationDetails": [],
|
|
197
|
+
"behaviorRules": [{ "scenario": "情境", "behavior": "行為" }],
|
|
198
|
+
"acceptanceCriteria": [],
|
|
199
|
+
"dependsOnKeys": [],
|
|
200
|
+
"flowcharts": [{ "id": "stable-flow-id", "title": "流程名稱", "source": "flowchart TD\\n A[開始] --> B[完成]", "evidence": [] }],
|
|
201
|
+
"wireframe": "",
|
|
202
|
+
"unresolvedItems": [],
|
|
203
|
+
"evidence": []
|
|
204
|
+
}]
|
|
205
|
+
}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 解析並驗證 AI 子任務拆分結果
|
|
210
|
+
* @param {string} content
|
|
211
|
+
* @param {number|string} parentIssueId
|
|
212
|
+
* @param {{maxSubtasks?: number}} options
|
|
213
|
+
* @returns {object}
|
|
214
|
+
*/
|
|
215
|
+
export function parseSubtaskAnalysis(content, parentIssueId, { maxSubtasks = DEFAULT_MAX_SUBTASKS } = {}) {
|
|
216
|
+
assertMaxSubtasks(maxSubtasks);
|
|
217
|
+
const parsed = normalizeAnalysisLanguage(AIClient.parseJSON(content));
|
|
218
|
+
return validateSubtasks(parsed, parentIssueId, maxSubtasks);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 呼叫 AI 產生主 Issue 的子任務拆分
|
|
223
|
+
* @param {{issue: object, maxSubtasks?: number, aiClient?: typeof AIClient, model?: string, maxRetries?: number}} input
|
|
224
|
+
* @returns {Promise<object>}
|
|
225
|
+
*/
|
|
226
|
+
export async function analyzeSubtasks({
|
|
227
|
+
issue,
|
|
228
|
+
maxSubtasks = DEFAULT_MAX_SUBTASKS,
|
|
229
|
+
aiClient = AIClient,
|
|
230
|
+
model = 'claude-haiku-4.5',
|
|
231
|
+
maxRetries = 3,
|
|
232
|
+
}) {
|
|
233
|
+
const prompt = buildSubtaskAnalysisPrompt({ issue, maxSubtasks });
|
|
234
|
+
const content = await aiClient.sendAndWait(prompt, model, maxRetries);
|
|
235
|
+
return parseSubtaskAnalysis(content, issue.id, { maxSubtasks });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export { DEFAULT_MAX_SUBTASKS, MAX_MAX_SUBTASKS, MIN_MAX_SUBTASKS };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
const MANAGED_KEY_PREFIX = 'ai-git-tools:redmine-subtask';
|
|
4
|
+
|
|
5
|
+
function asString(value) {
|
|
6
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function asStringArray(value) {
|
|
10
|
+
return Array.isArray(value)
|
|
11
|
+
? value.filter(item => typeof item === 'string').map(item => item.trim()).filter(Boolean)
|
|
12
|
+
: [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function bulletList(items) {
|
|
16
|
+
return asStringArray(items).map(item => `- ${item}`).join('\n');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function addSection(sections, title, body) {
|
|
20
|
+
const content = asString(body);
|
|
21
|
+
if (content) sections.push(`## ${title}\n\n${content}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatScope(subtask) {
|
|
25
|
+
const parts = [];
|
|
26
|
+
if (subtask.inScope?.length) parts.push(`### 包含\n${bulletList(subtask.inScope)}`);
|
|
27
|
+
if (subtask.outOfScope?.length) parts.push(`### 不包含\n${bulletList(subtask.outOfScope)}`);
|
|
28
|
+
return parts.join('\n\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatBehaviorRules(rules) {
|
|
32
|
+
const rows = Array.isArray(rules)
|
|
33
|
+
? rules
|
|
34
|
+
.filter(rule => rule?.scenario && rule?.behavior)
|
|
35
|
+
.map(rule => `| ${String(rule.scenario).replaceAll('|', '\\|')} | ${String(rule.behavior).replaceAll('|', '\\|')} |`)
|
|
36
|
+
: [];
|
|
37
|
+
return rows.length > 0 ? `| 情境 | 行為 |\n|---|---|\n${rows.join('\n')}` : '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatMermaid(source) {
|
|
41
|
+
let cleanSource = asString(source)
|
|
42
|
+
.replace(/^```mermaid\s*/i, '')
|
|
43
|
+
.replace(/^```\s*/, '')
|
|
44
|
+
.replace(/\s*```$/, '')
|
|
45
|
+
.replace(/^\{\{\s*mermaid\s*/i, '')
|
|
46
|
+
.replace(/\s*\}\}\s*$/, '')
|
|
47
|
+
.trim();
|
|
48
|
+
if (!cleanSource || cleanSource.includes('{{mermaid') || cleanSource.includes('}}')) return '';
|
|
49
|
+
if (!/^(flowchart|graph|sequenceDiagram|stateDiagram|classDiagram|erDiagram|journey)\b/m.test(cleanSource)) return '';
|
|
50
|
+
return `{{mermaid\n${cleanSource}\n}}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatFlowcharts(flowcharts) {
|
|
54
|
+
if (!Array.isArray(flowcharts)) return '';
|
|
55
|
+
return flowcharts
|
|
56
|
+
.map(flowchart => {
|
|
57
|
+
const mermaid = formatMermaid(flowchart?.source);
|
|
58
|
+
if (!mermaid) return '';
|
|
59
|
+
const title = asString(flowchart.title) || '流程';
|
|
60
|
+
const id = asString(flowchart.id) || 'flowchart-1';
|
|
61
|
+
return `### ${title}\n\n<!-- flowchart-id: ${id} -->\n${mermaid}`;
|
|
62
|
+
})
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join('\n\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatWireframe(wireframe) {
|
|
68
|
+
const value = asString(wireframe);
|
|
69
|
+
return value ? `\`\`\`text\n${value}\n\`\`\`` : '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 建立受控的子任務識別字串
|
|
74
|
+
* @param {number|string} parentIssueId
|
|
75
|
+
* @param {string} key
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function buildManagedSubtaskKey(parentIssueId, key) {
|
|
79
|
+
return `${MANAGED_KEY_PREFIX} parent=${parentIssueId} key=${asString(key)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 從子任務 description 讀取受控識別字串
|
|
84
|
+
* @param {string} content
|
|
85
|
+
* @returns {string|null}
|
|
86
|
+
*/
|
|
87
|
+
export function getManagedSubtaskKey(content = '') {
|
|
88
|
+
const match = String(content).match(/<!--\s*(ai-git-tools:redmine-subtask parent=\S+ key=\S+)\s*-->/);
|
|
89
|
+
return match?.[1] || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 將單一候選子任務格式化為 Redmine description
|
|
94
|
+
* @param {{subtask: object, parentIssueId: number|string}} input
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function formatSubtaskContent({ subtask = {}, parentIssueId }) {
|
|
98
|
+
const sections = [];
|
|
99
|
+
addSection(sections, '目的', subtask.purpose);
|
|
100
|
+
addSection(sections, '工作範圍', formatScope(subtask));
|
|
101
|
+
addSection(sections, '實作重點', bulletList(subtask.implementationDetails));
|
|
102
|
+
addSection(sections, '行為規則', formatBehaviorRules(subtask.behaviorRules));
|
|
103
|
+
addSection(sections, '驗收條件', bulletList(subtask.acceptanceCriteria));
|
|
104
|
+
|
|
105
|
+
const dependencies = bulletList(subtask.dependsOnKeys);
|
|
106
|
+
const unresolvedItems = asStringArray(subtask.unresolvedItems);
|
|
107
|
+
const dependencyParts = [];
|
|
108
|
+
if (dependencies) dependencyParts.push(`### 依賴\n${dependencies}`);
|
|
109
|
+
if (unresolvedItems.length) {
|
|
110
|
+
dependencyParts.push(`### 待確認\n${unresolvedItems.map(item => `- [ ] ${item}`).join('\n')}`);
|
|
111
|
+
}
|
|
112
|
+
addSection(sections, '依賴與待確認', dependencyParts.join('\n\n'));
|
|
113
|
+
addSection(sections, '流程圖', formatFlowcharts(subtask.flowcharts));
|
|
114
|
+
addSection(sections, 'Wireframe', formatWireframe(subtask.wireframe));
|
|
115
|
+
addSection(sections, '證據', bulletList(subtask.evidence));
|
|
116
|
+
|
|
117
|
+
sections.push(`<!-- ${buildManagedSubtaskKey(parentIssueId, subtask.key)} -->`);
|
|
118
|
+
return sections.join('\n\n').trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 產生主 Issue 子任務 terminal preview
|
|
123
|
+
* @param {{parent: object, candidates: Array<object>}} input
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
export function formatSubtaskPreview({ parent = {}, candidates = [] }) {
|
|
127
|
+
const lines = [
|
|
128
|
+
chalk.bold.cyan('═'.repeat(72)),
|
|
129
|
+
chalk.bold('Redmine 子任務拆分預覽'),
|
|
130
|
+
chalk.bold.cyan('═'.repeat(72)),
|
|
131
|
+
'',
|
|
132
|
+
chalk.bold.cyan(`Issue #${parent.id || '—'}:${parent.subject || '無標題'}`),
|
|
133
|
+
'既有子任務:',
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
if (parent.children?.length) {
|
|
137
|
+
for (const child of parent.children) lines.push(`- #${child.id} ${child.subject || '無標題'}`);
|
|
138
|
+
} else {
|
|
139
|
+
lines.push('- 無');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
143
|
+
lines.push(
|
|
144
|
+
'',
|
|
145
|
+
chalk.bold.green(`[${index + 1}/${candidates.length}] ${candidate.title || '無標題'}`),
|
|
146
|
+
`分類:${candidate.category || '—'}`,
|
|
147
|
+
candidate.dependsOnKeys?.length ? `依賴:${candidate.dependsOnKeys.join('、')}` : '依賴:無',
|
|
148
|
+
candidate.evidence?.length ? `依據:${candidate.evidence.join('、')}` : '依據:未提供',
|
|
149
|
+
candidate.content || '',
|
|
150
|
+
chalk.dim('─'.repeat(72))
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
lines.push('', chalk.yellow('尚未修改 Redmine。請審核 title 與 content 後再使用 --apply。'));
|
|
155
|
+
return lines.join('\n').trim();
|
|
156
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { analyzeSubtasks as defaultAnalyzeSubtasks } from './subtask-analyzer.js';
|
|
2
|
+
import {
|
|
3
|
+
formatSubtaskContent,
|
|
4
|
+
getManagedSubtaskKey,
|
|
5
|
+
buildManagedSubtaskKey,
|
|
6
|
+
} from './subtask-formatters.js';
|
|
7
|
+
|
|
8
|
+
function getProjectId(issue = {}) {
|
|
9
|
+
return issue.projectId ?? issue.project?.id ?? null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getTrackerId(issue = {}) {
|
|
13
|
+
return issue.trackerId ?? issue.tracker?.id ?? null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sameValue(left, right) {
|
|
17
|
+
return (left ?? null) === (right ?? null);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getParentSnapshot(parent) {
|
|
21
|
+
const snapshot = { ...parent, children: Array.isArray(parent.children) ? parent.children : [] };
|
|
22
|
+
delete snapshot.raw;
|
|
23
|
+
return snapshot;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertParentMetadata(parent) {
|
|
27
|
+
if (getProjectId(parent) === null || getTrackerId(parent) === null) {
|
|
28
|
+
throw new Error(`Issue #${parent.id} 缺少建立子任務需要的 project 或 tracker 資訊`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sanitizeError(error, client) {
|
|
33
|
+
const secret = client?.apiKey;
|
|
34
|
+
return secret ? String(error?.message || error).replaceAll(secret, '[REDACTED]') : String(error?.message || error);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasParentConflict(expected, current) {
|
|
38
|
+
return expected.subject !== current.subject
|
|
39
|
+
|| expected.description !== current.description
|
|
40
|
+
|| !sameValue(getProjectId(expected), getProjectId(current))
|
|
41
|
+
|| !sameValue(getTrackerId(expected), getTrackerId(current));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getExistingChildTitles(children = []) {
|
|
45
|
+
return new Set(children.map(child => String(child.subject || '').trim().toLocaleLowerCase()).filter(Boolean));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getExistingManagedKeys(children = []) {
|
|
49
|
+
return new Set(children.map(child => getManagedSubtaskKey(child.description)).filter(Boolean));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function loadChildDetails(client, children = []) {
|
|
53
|
+
if (typeof client?.getIssue !== 'function') return children;
|
|
54
|
+
return Promise.all(children.map(async child => {
|
|
55
|
+
if (child.description || child.id === undefined || child.id === null) return child;
|
|
56
|
+
try {
|
|
57
|
+
const detail = await client.getIssue(child.id, { include: '' });
|
|
58
|
+
return { ...child, description: detail.description || '' };
|
|
59
|
+
} catch {
|
|
60
|
+
return child;
|
|
61
|
+
}
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 產生主 Issue 子任務 draft
|
|
67
|
+
* @param {{parentIssueId: number|string, client: object, analyzeSubtasksFn?: Function, maxSubtasks?: number, model?: string, maxRetries?: number, onProgress?: Function}} options
|
|
68
|
+
* @returns {Promise<object>}
|
|
69
|
+
*/
|
|
70
|
+
export async function generateSubtaskDraft({
|
|
71
|
+
parentIssueId,
|
|
72
|
+
client,
|
|
73
|
+
analyzeSubtasksFn = defaultAnalyzeSubtasks,
|
|
74
|
+
maxSubtasks = 8,
|
|
75
|
+
model,
|
|
76
|
+
maxRetries,
|
|
77
|
+
onProgress = () => {},
|
|
78
|
+
}) {
|
|
79
|
+
onProgress({ phase: 'read-parent', parentIssueId });
|
|
80
|
+
const parent = await client.getIssue(parentIssueId, { include: 'children,relations' });
|
|
81
|
+
assertParentMetadata(parent);
|
|
82
|
+
onProgress({ phase: 'parent-read', parentIssueId, subject: parent.subject });
|
|
83
|
+
onProgress({ phase: 'analyze-subtasks', parentIssueId });
|
|
84
|
+
const analysis = await analyzeSubtasksFn({
|
|
85
|
+
issue: parent,
|
|
86
|
+
maxSubtasks,
|
|
87
|
+
model,
|
|
88
|
+
maxRetries,
|
|
89
|
+
});
|
|
90
|
+
onProgress({ phase: 'subtasks-analyzed', parentIssueId, count: analysis.subtasks.length });
|
|
91
|
+
|
|
92
|
+
const subtasks = analysis.subtasks.map(subtask => ({
|
|
93
|
+
...subtask,
|
|
94
|
+
title: subtask.title,
|
|
95
|
+
content: formatSubtaskContent({ subtask, parentIssueId: parent.id }),
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
version: 1,
|
|
100
|
+
generatedAt: new Date().toISOString(),
|
|
101
|
+
parent: getParentSnapshot(parent),
|
|
102
|
+
subtasks,
|
|
103
|
+
unresolvedItems: analysis.unresolvedItems || [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 套用已審核的子任務 draft
|
|
109
|
+
* @param {{draft: object, client: object, force?: boolean, onProgress?: Function}} options
|
|
110
|
+
* @returns {Promise<Array<object>>}
|
|
111
|
+
*/
|
|
112
|
+
export async function applySubtaskDraft({ draft, client, force = false, onProgress = () => {} }) {
|
|
113
|
+
validateSubtaskDraft(draft);
|
|
114
|
+
const parentIssueId = draft.parent.id;
|
|
115
|
+
const current = await client.getIssue(parentIssueId, { include: 'children,relations' });
|
|
116
|
+
if (!force && hasParentConflict(draft.parent, current)) {
|
|
117
|
+
return [{ parentIssueId, blocked: true, reason: 'conflict' }];
|
|
118
|
+
}
|
|
119
|
+
assertParentMetadata(draft.parent);
|
|
120
|
+
|
|
121
|
+
const children = await loadChildDetails(
|
|
122
|
+
client,
|
|
123
|
+
Array.isArray(current.children) ? current.children : []
|
|
124
|
+
);
|
|
125
|
+
const managedKeys = getExistingManagedKeys(children);
|
|
126
|
+
const titles = getExistingChildTitles(children);
|
|
127
|
+
const results = [];
|
|
128
|
+
for (const [index, subtask] of draft.subtasks.entries()) {
|
|
129
|
+
onProgress({
|
|
130
|
+
phase: 'create-child',
|
|
131
|
+
index: index + 1,
|
|
132
|
+
total: draft.subtasks.length,
|
|
133
|
+
parentIssueId,
|
|
134
|
+
key: subtask.key,
|
|
135
|
+
title: subtask.title,
|
|
136
|
+
});
|
|
137
|
+
const managedKey = buildManagedSubtaskKey(parentIssueId, subtask.key);
|
|
138
|
+
if (managedKeys.has(managedKey) || titles.has(String(subtask.title).trim().toLocaleLowerCase())) {
|
|
139
|
+
results.push({ key: subtask.key, title: subtask.title, skipped: true, reason: 'duplicate' });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const child = await client.createIssue({
|
|
145
|
+
projectId: getProjectId(draft.parent),
|
|
146
|
+
trackerId: getTrackerId(draft.parent),
|
|
147
|
+
subject: subtask.title,
|
|
148
|
+
description: subtask.content,
|
|
149
|
+
parentIssueId,
|
|
150
|
+
});
|
|
151
|
+
results.push({
|
|
152
|
+
key: subtask.key,
|
|
153
|
+
title: subtask.title,
|
|
154
|
+
created: true,
|
|
155
|
+
childId: child?.id ?? child?.issue?.id ?? null,
|
|
156
|
+
});
|
|
157
|
+
managedKeys.add(managedKey);
|
|
158
|
+
titles.add(String(subtask.title).trim().toLocaleLowerCase());
|
|
159
|
+
} catch (error) {
|
|
160
|
+
results.push({
|
|
161
|
+
key: subtask.key,
|
|
162
|
+
title: subtask.title,
|
|
163
|
+
created: false,
|
|
164
|
+
error: sanitizeError(error, client),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return results;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 驗證子任務 draft 基本結構
|
|
173
|
+
* @param {object} draft
|
|
174
|
+
*/
|
|
175
|
+
export function validateSubtaskDraft(draft = {}) {
|
|
176
|
+
if (draft.version !== 1 || !draft.parent || !Array.isArray(draft.subtasks) || draft.subtasks.length === 0) {
|
|
177
|
+
throw new Error('子任務 draft 格式不受支援');
|
|
178
|
+
}
|
|
179
|
+
if (draft.parent.id === undefined || draft.parent.id === null) {
|
|
180
|
+
throw new Error('子任務 draft 缺少 parent Issue ID');
|
|
181
|
+
}
|
|
182
|
+
for (const subtask of draft.subtasks) {
|
|
183
|
+
if (!subtask?.key || !subtask.title?.trim() || !subtask.content?.trim()) {
|
|
184
|
+
throw new Error('子任務 draft 缺少 key、title 或 content');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 將子任務 draft 序列化為 JSON
|
|
191
|
+
* @param {object} draft
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
194
|
+
export function serializeSubtaskDraft(draft) {
|
|
195
|
+
validateSubtaskDraft(draft);
|
|
196
|
+
return `${JSON.stringify(draft, null, 2)}\n`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 解析子任務 draft JSON
|
|
201
|
+
* @param {string} content
|
|
202
|
+
* @returns {object}
|
|
203
|
+
*/
|
|
204
|
+
export function parseSubtaskDraft(content) {
|
|
205
|
+
const draft = JSON.parse(content);
|
|
206
|
+
validateSubtaskDraft(draft);
|
|
207
|
+
return draft;
|
|
208
|
+
}
|