ai-git-tools 2.1.15 → 2.1.16
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 +4 -1
- package/bin/cli.js +1 -0
- package/package.json +1 -1
- package/src/commands/redmine-subtasks.js +8 -2
- package/src/core/ai-client.js +1 -7
- package/src/redmine/redmine-client.js +3 -2
- package/src/redmine/subtask-sync.js +45 -4
package/README.md
CHANGED
|
@@ -411,6 +411,9 @@ ai-git-tools redmine-subtasks --issue 18793 --preview --output redmine-subtasks.
|
|
|
411
411
|
|
|
412
412
|
# 確認草稿中的 title 與 content 後才建立子任務
|
|
413
413
|
ai-git-tools redmine-subtasks --apply --from redmine-subtasks.json
|
|
414
|
+
|
|
415
|
+
# 重新拆分後,覆蓋該 draft 快照中的既有受控子任務
|
|
416
|
+
ai-git-tools redmine-subtasks --apply --from redmine-subtasks.json --replace
|
|
414
417
|
```
|
|
415
418
|
|
|
416
419
|
可以明確指定範圍;目前只接受 `frontend`:
|
|
@@ -448,7 +451,7 @@ flowchart TD
|
|
|
448
451
|
|
|
449
452
|
子任務涉及頁面但主 Issue 沒有明確畫面設計時,content 會附上純文字 wireframe,只描述資訊區塊、主要操作與已知狀態,不猜測顏色、尺寸或像素。沒有可靠流程或畫面需求時,對應區段會省略,未決事項會保留為待確認項目。
|
|
450
453
|
|
|
451
|
-
這個流程使用 schema v2 draft。舊版 v1 草稿不可直接套用,請重新執行 preview。apply 前會重新讀取主 Issue;若 subject、description、project 或 tracker 已變更,預設會停止建立,確認變更仍可套用時才使用 `--force`。`--force` 只略過 parent snapshot conflict,不會略過 schema、payload 或 duplicate validation。既有子任務若有相同 managed key 或完全相同 title,會略過以避免重複建立;children summary 沒有 description 時,工具會再讀取 child detail 判斷 managed key
|
|
454
|
+
這個流程使用 schema v2 draft。舊版 v1 草稿不可直接套用,請重新執行 preview。apply 前會重新讀取主 Issue;若 subject、description、project 或 tracker 已變更,預設會停止建立,確認變更仍可套用時才使用 `--force`。`--force` 只略過 parent snapshot conflict,不會略過 schema、payload 或 duplicate validation。既有子任務若有相同 managed key 或完全相同 title,會略過以避免重複建立;children summary 沒有 description 時,工具會再讀取 child detail 判斷 managed key。若重新產生 preview 後要覆蓋上一次拆分的內容,請在 apply 時加上 `--replace`;它只會依 draft 快照順序覆蓋帶有工具 managed marker 的既有子任務,數量增加時建立新的子任務,數量減少時不刪除多出的舊項目。多筆建立或更新採逐筆處理,部分成功不會回滾已建立或已更新的子任務。
|
|
452
455
|
|
|
453
456
|
此命令只建立主 Issue 底下的子任務,不會修改主 Issue 的 status、description、notes、done ratio 或完成欄位。API key 沿用上方的 `REDMINE_API_KEY` 環境變數,不會放入 CLI 參數、草稿或輸出內容;AI 產出的拆分結果仍必須由使用者審核。
|
|
454
457
|
|
package/bin/cli.js
CHANGED
|
@@ -117,6 +117,7 @@ registerCommand(program, 'redmine-subtasks', '分析主 Issue 的 Frontend 需
|
|
|
117
117
|
{ flags: '--apply', description: '套用已審核的子任務草稿' },
|
|
118
118
|
{ flags: '--from <file>', description: '指定要套用的子任務草稿 JSON' },
|
|
119
119
|
{ flags: '--force', description: '強制略過主 Issue 內容衝突檢查' },
|
|
120
|
+
{ flags: '--replace', description: '覆蓋 draft 快照中的既有受控子任務' },
|
|
120
121
|
], redmineSubtasksCommand);
|
|
121
122
|
|
|
122
123
|
program.parse();
|
package/package.json
CHANGED
|
@@ -28,6 +28,7 @@ export function validateRedmineSubtaskOptions(options = {}) {
|
|
|
28
28
|
if (options.from && !options.apply) throw new Error('--from 只能搭配 --apply 使用');
|
|
29
29
|
if (options.output && !options.preview) throw new Error('--output 只能搭配 --preview 使用');
|
|
30
30
|
if (options.force && !options.apply) throw new Error('--force 只能搭配 --apply 使用');
|
|
31
|
+
if (options.replace && !options.apply) throw new Error('--replace 只能搭配 --apply 使用');
|
|
31
32
|
if (options.scope !== undefined && options.scope !== 'frontend') {
|
|
32
33
|
throw new Error('目前 redmine-subtasks 只支援 frontend scope');
|
|
33
34
|
}
|
|
@@ -75,6 +76,8 @@ export function formatSubtaskProgress(progress = {}) {
|
|
|
75
76
|
return `[${progress.index}/${progress.total}] 整理 screen wireframe...`;
|
|
76
77
|
case 'create-child':
|
|
77
78
|
return `[${progress.index}/${progress.total}] 建立子任務:${progress.title || '無標題'}`;
|
|
79
|
+
case 'replace-child':
|
|
80
|
+
return `[${progress.index}/${progress.total}] 覆蓋子任務:${progress.title || '無標題'}`;
|
|
78
81
|
case 'preview':
|
|
79
82
|
return '預覽完成,尚未修改 Redmine';
|
|
80
83
|
default:
|
|
@@ -94,7 +97,9 @@ function printProgress(progress) {
|
|
|
94
97
|
|
|
95
98
|
function printApplyResults(results) {
|
|
96
99
|
for (const result of results) {
|
|
97
|
-
if (result.
|
|
100
|
+
if (result.updated) {
|
|
101
|
+
console.log(`✅ 子任務「${result.title}」已覆蓋:#${result.childId || '—'}`);
|
|
102
|
+
} else if (result.created) {
|
|
98
103
|
console.log(`✅ 子任務「${result.title}」已建立:#${result.childId || '—'}`);
|
|
99
104
|
} else if (result.skipped) {
|
|
100
105
|
console.log(`⚠️ 子任務「${result.title}」略過:已有相同子任務`);
|
|
@@ -110,7 +115,7 @@ function printApplyResults(results) {
|
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
function hasSubtaskFailures(results = []) {
|
|
113
|
-
return results.some(result => result.blocked || result.created === false);
|
|
118
|
+
return results.some(result => result.blocked || result.created === false || result.updated === false);
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
/**
|
|
@@ -131,6 +136,7 @@ export async function redmineSubtasksCommand(options = {}) {
|
|
|
131
136
|
draft,
|
|
132
137
|
client,
|
|
133
138
|
force: options.force,
|
|
139
|
+
replace: options.replace,
|
|
134
140
|
onProgress: printProgress,
|
|
135
141
|
});
|
|
136
142
|
printApplyResults(results);
|
package/src/core/ai-client.js
CHANGED
|
@@ -110,13 +110,7 @@ export class AIClient {
|
|
|
110
110
|
onPermissionRequest: approveAll,
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
const responsePromise = session.sendAndWait({ prompt });
|
|
115
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
116
|
-
setTimeout(() => reject(new Error(`AI 請求超時 (${timeout}ms)`)), timeout);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
const response = await Promise.race([responsePromise, timeoutPromise]);
|
|
113
|
+
const response = await session.sendAndWait({ prompt }, timeout);
|
|
120
114
|
|
|
121
115
|
const content = response?.data?.content || '';
|
|
122
116
|
return toTraditionalChinese(content.trim());
|
|
@@ -175,14 +175,15 @@ export class RedmineClient {
|
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
177
|
* @param {number|string} issueId
|
|
178
|
-
* @param {{statusId?: number, doneRatio?: number, dueDate?: string, description?: string, notes?: string, customFields?: Array<object>}} update
|
|
178
|
+
* @param {{statusId?: number, doneRatio?: number, dueDate?: string, subject?: string, description?: string, notes?: string, customFields?: Array<object>}} update
|
|
179
179
|
* @returns {Promise<object>}
|
|
180
180
|
*/
|
|
181
|
-
async updateIssue(issueId, { statusId, doneRatio, dueDate, description, notes, customFields }) {
|
|
181
|
+
async updateIssue(issueId, { statusId, doneRatio, dueDate, subject, description, notes, customFields }) {
|
|
182
182
|
const issue = {};
|
|
183
183
|
if (statusId !== undefined && statusId !== null) issue.status_id = statusId;
|
|
184
184
|
if (doneRatio !== undefined && doneRatio !== null) issue.done_ratio = doneRatio;
|
|
185
185
|
if (dueDate !== undefined && dueDate !== null) issue.due_date = dueDate;
|
|
186
|
+
if (subject !== undefined) issue.subject = subject;
|
|
186
187
|
if (description !== undefined) issue.description = description;
|
|
187
188
|
if (notes) issue.notes = notes;
|
|
188
189
|
if (Array.isArray(customFields) && customFields.length > 0) {
|
|
@@ -93,6 +93,14 @@ function getExistingManagedKeys(children = []) {
|
|
|
93
93
|
return new Set(children.map(child => getManagedSubtaskKey(child.description)).filter(Boolean));
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
function getReplaceableChildren(parent, children, parentIssueId) {
|
|
97
|
+
const childrenById = new Map(children.map(child => [String(child.id), child]));
|
|
98
|
+
const managedPrefix = buildManagedSubtaskKey(parentIssueId, '');
|
|
99
|
+
return (Array.isArray(parent?.children) ? parent.children : [])
|
|
100
|
+
.map(child => childrenById.get(String(child?.id)))
|
|
101
|
+
.filter(child => getManagedSubtaskKey(child?.description)?.startsWith(managedPrefix));
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
async function loadChildDetails(client, children = []) {
|
|
97
105
|
const unavailableChildIds = [];
|
|
98
106
|
if (typeof client?.getIssue !== 'function') {
|
|
@@ -203,10 +211,10 @@ export async function generateSubtaskDraft({
|
|
|
203
211
|
|
|
204
212
|
/**
|
|
205
213
|
* 套用已審核的子任務 draft
|
|
206
|
-
* @param {{draft: object, client: object, force?: boolean, onProgress?: Function}} options
|
|
214
|
+
* @param {{draft: object, client: object, force?: boolean, replace?: boolean, onProgress?: Function}} options
|
|
207
215
|
* @returns {Promise<Array<object>>}
|
|
208
216
|
*/
|
|
209
|
-
export async function applySubtaskDraft({ draft, client, force = false, onProgress = () => {} }) {
|
|
217
|
+
export async function applySubtaskDraft({ draft, client, force = false, replace = false, onProgress = () => {} }) {
|
|
210
218
|
validateSubtaskDraft(draft);
|
|
211
219
|
const parentIssueId = draft.parent.id;
|
|
212
220
|
const current = await client.getIssue(parentIssueId, { include: 'children,relations' });
|
|
@@ -228,20 +236,53 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
|
|
|
228
236
|
}];
|
|
229
237
|
}
|
|
230
238
|
const children = childDetails.children;
|
|
239
|
+
const replaceableChildren = replace
|
|
240
|
+
? getReplaceableChildren(draft.parent, children, parentIssueId)
|
|
241
|
+
: [];
|
|
242
|
+
const replacedChildIds = new Set();
|
|
231
243
|
const managedKeys = getExistingManagedKeys(children);
|
|
232
244
|
const titles = getExistingChildTitles(children);
|
|
233
245
|
const results = [];
|
|
234
246
|
for (const [index, subtask] of draft.subtasks.entries()) {
|
|
235
247
|
const subtaskId = subtask.id || subtask.key;
|
|
248
|
+
const managedKey = buildManagedSubtaskKey(parentIssueId, subtaskId);
|
|
249
|
+
const replacement = replace
|
|
250
|
+
? replaceableChildren.find(child => !replacedChildIds.has(String(child.id))
|
|
251
|
+
&& getManagedSubtaskKey(child.description) === managedKey)
|
|
252
|
+
|| replaceableChildren.find(child => !replacedChildIds.has(String(child.id)))
|
|
253
|
+
: null;
|
|
236
254
|
onProgress({
|
|
237
|
-
phase: 'create-child',
|
|
255
|
+
phase: replacement ? 'replace-child' : 'create-child',
|
|
238
256
|
index: index + 1,
|
|
239
257
|
total: draft.subtasks.length,
|
|
240
258
|
parentIssueId,
|
|
241
259
|
key: subtaskId,
|
|
242
260
|
title: subtask.title,
|
|
243
261
|
});
|
|
244
|
-
|
|
262
|
+
if (replacement) {
|
|
263
|
+
replacedChildIds.add(String(replacement.id));
|
|
264
|
+
try {
|
|
265
|
+
await client.updateIssue(replacement.id, {
|
|
266
|
+
subject: subtask.title,
|
|
267
|
+
description: subtask.content,
|
|
268
|
+
});
|
|
269
|
+
results.push({
|
|
270
|
+
...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
|
|
271
|
+
title: subtask.title,
|
|
272
|
+
updated: true,
|
|
273
|
+
childId: replacement.id,
|
|
274
|
+
});
|
|
275
|
+
} catch (error) {
|
|
276
|
+
results.push({
|
|
277
|
+
...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
|
|
278
|
+
title: subtask.title,
|
|
279
|
+
updated: false,
|
|
280
|
+
childId: replacement.id,
|
|
281
|
+
error: sanitizeError(error, client),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
245
286
|
if (managedKeys.has(managedKey) || titles.has(String(subtask.title).trim().toLocaleLowerCase())) {
|
|
246
287
|
results.push({
|
|
247
288
|
...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
|