ai-git-tools 2.1.14 → 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.
@@ -1,4 +1,10 @@
1
- import { analyzeSubtasks as defaultAnalyzeSubtasks } from './subtask-analyzer.js';
1
+ import {
2
+ analyzeSubtasks as defaultAnalyzeSubtasks,
3
+ MIN_MAX_SUBTASKS,
4
+ MAX_MAX_SUBTASKS,
5
+ normalizeFrontendAnalysis,
6
+ validateFrontendAnalysis,
7
+ } from './subtask-analyzer.js';
2
8
  import {
3
9
  formatSubtaskContent,
4
10
  getManagedSubtaskKey,
@@ -17,10 +23,33 @@ function sameValue(left, right) {
17
23
  return (left ?? null) === (right ?? null);
18
24
  }
19
25
 
26
+ function normalizeRelations(relations = []) {
27
+ return (Array.isArray(relations) ? relations : [])
28
+ .map(relation => ({
29
+ id: relation?.id ?? null,
30
+ issueId: relation?.issue_id ?? relation?.issueId ?? null,
31
+ issueToId: relation?.issue_to_id ?? relation?.issueToId ?? null,
32
+ relationType: relation?.relation_type ?? relation?.relationType ?? null,
33
+ }))
34
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
35
+ }
36
+
20
37
  function getParentSnapshot(parent) {
21
- const snapshot = { ...parent, children: Array.isArray(parent.children) ? parent.children : [] };
22
- delete snapshot.raw;
23
- return snapshot;
38
+ return {
39
+ id: parent.id,
40
+ subject: parent.subject || '',
41
+ description: parent.description || '',
42
+ projectId: getProjectId(parent),
43
+ trackerId: getTrackerId(parent),
44
+ children: Array.isArray(parent.children)
45
+ ? parent.children.map(child => ({
46
+ id: child.id,
47
+ subject: child.subject || '',
48
+ description: child.description || '',
49
+ }))
50
+ : [],
51
+ relations: normalizeRelations(parent.relations),
52
+ };
24
53
  }
25
54
 
26
55
  function assertParentMetadata(parent) {
@@ -34,11 +63,26 @@ function sanitizeError(error, client) {
34
63
  return secret ? String(error?.message || error).replaceAll(secret, '[REDACTED]') : String(error?.message || error);
35
64
  }
36
65
 
66
+ function assertDraftDoesNotContainPrivateFields(value, path = 'draft') {
67
+ if (!value || typeof value !== 'object') return;
68
+ if (Array.isArray(value)) {
69
+ value.forEach((item, index) => assertDraftDoesNotContainPrivateFields(item, `${path}[${index}]`));
70
+ return;
71
+ }
72
+ for (const [key, item] of Object.entries(value)) {
73
+ if (/raw|secret|token|password|private|credential|api[-_]?key/i.test(key)) {
74
+ throw new Error(`Frontend 子任務 draft 含有禁止保存的欄位:${path}.${key}`);
75
+ }
76
+ assertDraftDoesNotContainPrivateFields(item, `${path}.${key}`);
77
+ }
78
+ }
79
+
37
80
  function hasParentConflict(expected, current) {
38
81
  return expected.subject !== current.subject
39
82
  || expected.description !== current.description
40
83
  || !sameValue(getProjectId(expected), getProjectId(current))
41
- || !sameValue(getTrackerId(expected), getTrackerId(current));
84
+ || !sameValue(getTrackerId(expected), getTrackerId(current))
85
+ || JSON.stringify(normalizeRelations(expected.relations)) !== JSON.stringify(normalizeRelations(current.relations));
42
86
  }
43
87
 
44
88
  function getExistingChildTitles(children = []) {
@@ -49,17 +93,39 @@ function getExistingManagedKeys(children = []) {
49
93
  return new Set(children.map(child => getManagedSubtaskKey(child.description)).filter(Boolean));
50
94
  }
51
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
+
52
104
  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;
105
+ const unavailableChildIds = [];
106
+ if (typeof client?.getIssue !== 'function') {
107
+ return {
108
+ children,
109
+ unavailableChildIds: children
110
+ .filter(child => !child.description)
111
+ .map(child => child.id ?? 'unknown'),
112
+ };
113
+ }
114
+ const detailedChildren = await Promise.all(children.map(async child => {
115
+ if (child.description) return child;
116
+ if (child.id === undefined || child.id === null) {
117
+ unavailableChildIds.push('unknown');
118
+ return child;
119
+ }
56
120
  try {
57
- const detail = await client.getIssue(child.id, { include: '' });
121
+ const detail = await client.getIssue(child.id);
58
122
  return { ...child, description: detail.description || '' };
59
123
  } catch {
124
+ unavailableChildIds.push(child.id);
60
125
  return child;
61
126
  }
62
127
  }));
128
+ return { children: detailedChildren, unavailableChildIds };
63
129
  }
64
130
 
65
131
  /**
@@ -72,6 +138,7 @@ export async function generateSubtaskDraft({
72
138
  client,
73
139
  analyzeSubtasksFn = defaultAnalyzeSubtasks,
74
140
  maxSubtasks = 8,
141
+ repositoryContext = {},
75
142
  model,
76
143
  maxRetries,
77
144
  onProgress = () => {},
@@ -80,36 +147,74 @@ export async function generateSubtaskDraft({
80
147
  const parent = await client.getIssue(parentIssueId, { include: 'children,relations' });
81
148
  assertParentMetadata(parent);
82
149
  onProgress({ phase: 'parent-read', parentIssueId, subject: parent.subject });
150
+ onProgress({ phase: 'scope-analysis', parentIssueId });
83
151
  onProgress({ phase: 'analyze-subtasks', parentIssueId });
84
152
  const analysis = await analyzeSubtasksFn({
85
153
  issue: parent,
86
154
  maxSubtasks,
155
+ repositoryContext,
87
156
  model,
88
157
  maxRetries,
89
158
  });
159
+ if (analysis.schemaVersion !== 2) {
160
+ throw new Error('Frontend 子任務分析必須回傳 schemaVersion 2,請重新產生 preview');
161
+ }
162
+ validateFrontendAnalysis(analysis, parentIssueId, maxSubtasks);
90
163
  onProgress({ phase: 'subtasks-analyzed', parentIssueId, count: analysis.subtasks.length });
91
164
 
92
- const subtasks = analysis.subtasks.map(subtask => ({
93
- ...subtask,
94
- title: subtask.title,
95
- content: formatSubtaskContent({ subtask, parentIssueId: parent.id }),
96
- }));
165
+ if (analysis.scopeDecision?.status === 'none') {
166
+ throw new Error(`Issue #${parentIssueId} 沒有可建立的 Frontend 子任務`);
167
+ }
168
+ if (analysis.scopeDecision?.status === 'unclear') {
169
+ throw new Error(`Issue #${parentIssueId} 的 Frontend scope 不明,請先確認 unresolved items`);
170
+ }
171
+ if (!analysis.subtasks.length) {
172
+ throw new Error(`Issue #${parentIssueId} 沒有產生有效的 Frontend 子任務`);
173
+ }
174
+
175
+ const subtasks = analysis.subtasks.map((subtask, index) => {
176
+ onProgress({
177
+ phase: 'screen-enrichment',
178
+ index: index + 1,
179
+ total: analysis.subtasks.length,
180
+ parentIssueId,
181
+ title: subtask.title,
182
+ });
183
+ return {
184
+ ...subtask,
185
+ title: subtask.title,
186
+ content: formatSubtaskContent({
187
+ subtask,
188
+ parentIssueId: parent.id,
189
+ scopeDecision: analysis.scopeDecision,
190
+ evidence: analysis.evidence,
191
+ }),
192
+ };
193
+ });
97
194
 
98
195
  return {
99
- version: 1,
196
+ version: 2,
197
+ schemaVersion: 2,
100
198
  generatedAt: new Date().toISOString(),
199
+ analysisScope: analysis.analysisScope,
200
+ maxSubtasks,
201
+ isIndivisible: Boolean(analysis.isIndivisible),
202
+ indivisibleReason: analysis.indivisibleReason || '',
203
+ scopeDecision: analysis.scopeDecision,
204
+ evidence: analysis.evidence || [],
205
+ unresolvedItems: analysis.unresolvedItems || [],
101
206
  parent: getParentSnapshot(parent),
102
207
  subtasks,
103
- unresolvedItems: analysis.unresolvedItems || [],
104
208
  };
209
+
105
210
  }
106
211
 
107
212
  /**
108
213
  * 套用已審核的子任務 draft
109
- * @param {{draft: object, client: object, force?: boolean, onProgress?: Function}} options
214
+ * @param {{draft: object, client: object, force?: boolean, replace?: boolean, onProgress?: Function}} options
110
215
  * @returns {Promise<Array<object>>}
111
216
  */
112
- export async function applySubtaskDraft({ draft, client, force = false, onProgress = () => {} }) {
217
+ export async function applySubtaskDraft({ draft, client, force = false, replace = false, onProgress = () => {} }) {
113
218
  validateSubtaskDraft(draft);
114
219
  const parentIssueId = draft.parent.id;
115
220
  const current = await client.getIssue(parentIssueId, { include: 'children,relations' });
@@ -118,25 +223,73 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
118
223
  }
119
224
  assertParentMetadata(draft.parent);
120
225
 
121
- const children = await loadChildDetails(
226
+ const childDetails = await loadChildDetails(
122
227
  client,
123
228
  Array.isArray(current.children) ? current.children : []
124
229
  );
230
+ if (childDetails.unavailableChildIds.length > 0) {
231
+ return [{
232
+ parentIssueId,
233
+ blocked: true,
234
+ reason: 'child-detail-unavailable',
235
+ childIds: childDetails.unavailableChildIds,
236
+ }];
237
+ }
238
+ const children = childDetails.children;
239
+ const replaceableChildren = replace
240
+ ? getReplaceableChildren(draft.parent, children, parentIssueId)
241
+ : [];
242
+ const replacedChildIds = new Set();
125
243
  const managedKeys = getExistingManagedKeys(children);
126
244
  const titles = getExistingChildTitles(children);
127
245
  const results = [];
128
246
  for (const [index, subtask] of draft.subtasks.entries()) {
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;
129
254
  onProgress({
130
- phase: 'create-child',
255
+ phase: replacement ? 'replace-child' : 'create-child',
131
256
  index: index + 1,
132
257
  total: draft.subtasks.length,
133
258
  parentIssueId,
134
- key: subtask.key,
259
+ key: subtaskId,
135
260
  title: subtask.title,
136
261
  });
137
- const managedKey = buildManagedSubtaskKey(parentIssueId, subtask.key);
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
+ }
138
286
  if (managedKeys.has(managedKey) || titles.has(String(subtask.title).trim().toLocaleLowerCase())) {
139
- results.push({ key: subtask.key, title: subtask.title, skipped: true, reason: 'duplicate' });
287
+ results.push({
288
+ ...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
289
+ title: subtask.title,
290
+ skipped: true,
291
+ reason: 'duplicate',
292
+ });
140
293
  continue;
141
294
  }
142
295
 
@@ -149,7 +302,7 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
149
302
  parentIssueId,
150
303
  });
151
304
  results.push({
152
- key: subtask.key,
305
+ ...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
153
306
  title: subtask.title,
154
307
  created: true,
155
308
  childId: child?.id ?? child?.issue?.id ?? null,
@@ -158,7 +311,7 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
158
311
  titles.add(String(subtask.title).trim().toLocaleLowerCase());
159
312
  } catch (error) {
160
313
  results.push({
161
- key: subtask.key,
314
+ ...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
162
315
  title: subtask.title,
163
316
  created: false,
164
317
  error: sanitizeError(error, client),
@@ -173,17 +326,46 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
173
326
  * @param {object} draft
174
327
  */
175
328
  export function validateSubtaskDraft(draft = {}) {
176
- if (draft.version !== 1 || !draft.parent || !Array.isArray(draft.subtasks) || draft.subtasks.length === 0) {
177
- throw new Error('子任務 draft 格式不受支援');
329
+ assertDraftDoesNotContainPrivateFields(draft);
330
+ if (draft.version !== 2 || draft.schemaVersion !== 2) {
331
+ throw new Error('Frontend 子任務 draft v1 不受支援,請重新產生 schemaVersion 2 preview');
332
+ }
333
+ if (draft.analysisScope !== 'frontend') {
334
+ throw new Error('Frontend 子任務 draft 的 analysisScope 必須是 frontend');
335
+ }
336
+ const maxSubtasks = draft.maxSubtasks === undefined ? MAX_MAX_SUBTASKS : Number(draft.maxSubtasks);
337
+ if (!Number.isInteger(maxSubtasks) || maxSubtasks < MIN_MAX_SUBTASKS || maxSubtasks > MAX_MAX_SUBTASKS) {
338
+ throw new Error(`Frontend 子任務 draft 的 maxSubtasks 必須介於 ${MIN_MAX_SUBTASKS} 到 ${MAX_MAX_SUBTASKS} 之間`);
339
+ }
340
+ if (!draft.scopeDecision || !['full', 'partial'].includes(draft.scopeDecision.status)) {
341
+ throw new Error('Frontend 子任務 draft 缺少可套用的 scopeDecision');
342
+ }
343
+ if (!draft.parent || !Array.isArray(draft.subtasks) || draft.subtasks.length === 0) {
344
+ throw new Error('Frontend 子任務 draft 格式不受支援');
178
345
  }
179
346
  if (draft.parent.id === undefined || draft.parent.id === null) {
180
347
  throw new Error('子任務 draft 缺少 parent Issue ID');
181
348
  }
349
+ const ids = new Set();
182
350
  for (const subtask of draft.subtasks) {
183
- if (!subtask?.key || !subtask.title?.trim() || !subtask.content?.trim()) {
184
- throw new Error('子任務 draft 缺少 key、title 或 content');
351
+ if (!subtask?.id || !subtask.title?.trim() || !subtask.content?.trim()) {
352
+ throw new Error('Frontend 子任務 draft 缺少 id、title 或 content');
185
353
  }
354
+ if (ids.has(subtask.id)) throw new Error(`Frontend 子任務 draft ID 重複:${subtask.id}`);
355
+ ids.add(subtask.id);
186
356
  }
357
+ const normalizedAnalysis = normalizeFrontendAnalysis({
358
+ schemaVersion: draft.schemaVersion,
359
+ issueId: draft.parent.id,
360
+ analysisScope: draft.analysisScope,
361
+ isIndivisible: Boolean(draft.isIndivisible),
362
+ indivisibleReason: draft.indivisibleReason || '',
363
+ scopeDecision: draft.scopeDecision,
364
+ evidence: Array.isArray(draft.evidence) ? draft.evidence : [],
365
+ unresolvedItems: Array.isArray(draft.unresolvedItems) ? draft.unresolvedItems : [],
366
+ subtasks: draft.subtasks,
367
+ });
368
+ validateFrontendAnalysis(normalizedAnalysis, draft.parent.id, maxSubtasks);
187
369
  }
188
370
 
189
371
  /**