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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.1.15",
3
+ "version": "2.1.16",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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.created) {
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);
@@ -110,13 +110,7 @@ export class AIClient {
110
110
  onPermissionRequest: approveAll,
111
111
  });
112
112
 
113
- // 使用 Promise.race 實現超時控制
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
- const managedKey = buildManagedSubtaskKey(parentIssueId, subtaskId);
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 }),