ai-git-tools 2.1.14 → 2.1.15

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 = []) {
@@ -50,16 +94,30 @@ function getExistingManagedKeys(children = []) {
50
94
  }
51
95
 
52
96
  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;
97
+ const unavailableChildIds = [];
98
+ if (typeof client?.getIssue !== 'function') {
99
+ return {
100
+ children,
101
+ unavailableChildIds: children
102
+ .filter(child => !child.description)
103
+ .map(child => child.id ?? 'unknown'),
104
+ };
105
+ }
106
+ const detailedChildren = await Promise.all(children.map(async child => {
107
+ if (child.description) return child;
108
+ if (child.id === undefined || child.id === null) {
109
+ unavailableChildIds.push('unknown');
110
+ return child;
111
+ }
56
112
  try {
57
- const detail = await client.getIssue(child.id, { include: '' });
113
+ const detail = await client.getIssue(child.id);
58
114
  return { ...child, description: detail.description || '' };
59
115
  } catch {
116
+ unavailableChildIds.push(child.id);
60
117
  return child;
61
118
  }
62
119
  }));
120
+ return { children: detailedChildren, unavailableChildIds };
63
121
  }
64
122
 
65
123
  /**
@@ -72,6 +130,7 @@ export async function generateSubtaskDraft({
72
130
  client,
73
131
  analyzeSubtasksFn = defaultAnalyzeSubtasks,
74
132
  maxSubtasks = 8,
133
+ repositoryContext = {},
75
134
  model,
76
135
  maxRetries,
77
136
  onProgress = () => {},
@@ -80,28 +139,66 @@ export async function generateSubtaskDraft({
80
139
  const parent = await client.getIssue(parentIssueId, { include: 'children,relations' });
81
140
  assertParentMetadata(parent);
82
141
  onProgress({ phase: 'parent-read', parentIssueId, subject: parent.subject });
142
+ onProgress({ phase: 'scope-analysis', parentIssueId });
83
143
  onProgress({ phase: 'analyze-subtasks', parentIssueId });
84
144
  const analysis = await analyzeSubtasksFn({
85
145
  issue: parent,
86
146
  maxSubtasks,
147
+ repositoryContext,
87
148
  model,
88
149
  maxRetries,
89
150
  });
151
+ if (analysis.schemaVersion !== 2) {
152
+ throw new Error('Frontend 子任務分析必須回傳 schemaVersion 2,請重新產生 preview');
153
+ }
154
+ validateFrontendAnalysis(analysis, parentIssueId, maxSubtasks);
90
155
  onProgress({ phase: 'subtasks-analyzed', parentIssueId, count: analysis.subtasks.length });
91
156
 
92
- const subtasks = analysis.subtasks.map(subtask => ({
93
- ...subtask,
94
- title: subtask.title,
95
- content: formatSubtaskContent({ subtask, parentIssueId: parent.id }),
96
- }));
157
+ if (analysis.scopeDecision?.status === 'none') {
158
+ throw new Error(`Issue #${parentIssueId} 沒有可建立的 Frontend 子任務`);
159
+ }
160
+ if (analysis.scopeDecision?.status === 'unclear') {
161
+ throw new Error(`Issue #${parentIssueId} 的 Frontend scope 不明,請先確認 unresolved items`);
162
+ }
163
+ if (!analysis.subtasks.length) {
164
+ throw new Error(`Issue #${parentIssueId} 沒有產生有效的 Frontend 子任務`);
165
+ }
166
+
167
+ const subtasks = analysis.subtasks.map((subtask, index) => {
168
+ onProgress({
169
+ phase: 'screen-enrichment',
170
+ index: index + 1,
171
+ total: analysis.subtasks.length,
172
+ parentIssueId,
173
+ title: subtask.title,
174
+ });
175
+ return {
176
+ ...subtask,
177
+ title: subtask.title,
178
+ content: formatSubtaskContent({
179
+ subtask,
180
+ parentIssueId: parent.id,
181
+ scopeDecision: analysis.scopeDecision,
182
+ evidence: analysis.evidence,
183
+ }),
184
+ };
185
+ });
97
186
 
98
187
  return {
99
- version: 1,
188
+ version: 2,
189
+ schemaVersion: 2,
100
190
  generatedAt: new Date().toISOString(),
191
+ analysisScope: analysis.analysisScope,
192
+ maxSubtasks,
193
+ isIndivisible: Boolean(analysis.isIndivisible),
194
+ indivisibleReason: analysis.indivisibleReason || '',
195
+ scopeDecision: analysis.scopeDecision,
196
+ evidence: analysis.evidence || [],
197
+ unresolvedItems: analysis.unresolvedItems || [],
101
198
  parent: getParentSnapshot(parent),
102
199
  subtasks,
103
- unresolvedItems: analysis.unresolvedItems || [],
104
200
  };
201
+
105
202
  }
106
203
 
107
204
  /**
@@ -118,25 +215,40 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
118
215
  }
119
216
  assertParentMetadata(draft.parent);
120
217
 
121
- const children = await loadChildDetails(
218
+ const childDetails = await loadChildDetails(
122
219
  client,
123
220
  Array.isArray(current.children) ? current.children : []
124
221
  );
222
+ if (childDetails.unavailableChildIds.length > 0) {
223
+ return [{
224
+ parentIssueId,
225
+ blocked: true,
226
+ reason: 'child-detail-unavailable',
227
+ childIds: childDetails.unavailableChildIds,
228
+ }];
229
+ }
230
+ const children = childDetails.children;
125
231
  const managedKeys = getExistingManagedKeys(children);
126
232
  const titles = getExistingChildTitles(children);
127
233
  const results = [];
128
234
  for (const [index, subtask] of draft.subtasks.entries()) {
235
+ const subtaskId = subtask.id || subtask.key;
129
236
  onProgress({
130
237
  phase: 'create-child',
131
238
  index: index + 1,
132
239
  total: draft.subtasks.length,
133
240
  parentIssueId,
134
- key: subtask.key,
241
+ key: subtaskId,
135
242
  title: subtask.title,
136
243
  });
137
- const managedKey = buildManagedSubtaskKey(parentIssueId, subtask.key);
244
+ const managedKey = buildManagedSubtaskKey(parentIssueId, subtaskId);
138
245
  if (managedKeys.has(managedKey) || titles.has(String(subtask.title).trim().toLocaleLowerCase())) {
139
- results.push({ key: subtask.key, title: subtask.title, skipped: true, reason: 'duplicate' });
246
+ results.push({
247
+ ...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
248
+ title: subtask.title,
249
+ skipped: true,
250
+ reason: 'duplicate',
251
+ });
140
252
  continue;
141
253
  }
142
254
 
@@ -149,7 +261,7 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
149
261
  parentIssueId,
150
262
  });
151
263
  results.push({
152
- key: subtask.key,
264
+ ...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
153
265
  title: subtask.title,
154
266
  created: true,
155
267
  childId: child?.id ?? child?.issue?.id ?? null,
@@ -158,7 +270,7 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
158
270
  titles.add(String(subtask.title).trim().toLocaleLowerCase());
159
271
  } catch (error) {
160
272
  results.push({
161
- key: subtask.key,
273
+ ...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
162
274
  title: subtask.title,
163
275
  created: false,
164
276
  error: sanitizeError(error, client),
@@ -173,17 +285,46 @@ export async function applySubtaskDraft({ draft, client, force = false, onProgre
173
285
  * @param {object} draft
174
286
  */
175
287
  export function validateSubtaskDraft(draft = {}) {
176
- if (draft.version !== 1 || !draft.parent || !Array.isArray(draft.subtasks) || draft.subtasks.length === 0) {
177
- throw new Error('子任務 draft 格式不受支援');
288
+ assertDraftDoesNotContainPrivateFields(draft);
289
+ if (draft.version !== 2 || draft.schemaVersion !== 2) {
290
+ throw new Error('Frontend 子任務 draft v1 不受支援,請重新產生 schemaVersion 2 preview');
291
+ }
292
+ if (draft.analysisScope !== 'frontend') {
293
+ throw new Error('Frontend 子任務 draft 的 analysisScope 必須是 frontend');
294
+ }
295
+ const maxSubtasks = draft.maxSubtasks === undefined ? MAX_MAX_SUBTASKS : Number(draft.maxSubtasks);
296
+ if (!Number.isInteger(maxSubtasks) || maxSubtasks < MIN_MAX_SUBTASKS || maxSubtasks > MAX_MAX_SUBTASKS) {
297
+ throw new Error(`Frontend 子任務 draft 的 maxSubtasks 必須介於 ${MIN_MAX_SUBTASKS} 到 ${MAX_MAX_SUBTASKS} 之間`);
298
+ }
299
+ if (!draft.scopeDecision || !['full', 'partial'].includes(draft.scopeDecision.status)) {
300
+ throw new Error('Frontend 子任務 draft 缺少可套用的 scopeDecision');
301
+ }
302
+ if (!draft.parent || !Array.isArray(draft.subtasks) || draft.subtasks.length === 0) {
303
+ throw new Error('Frontend 子任務 draft 格式不受支援');
178
304
  }
179
305
  if (draft.parent.id === undefined || draft.parent.id === null) {
180
306
  throw new Error('子任務 draft 缺少 parent Issue ID');
181
307
  }
308
+ const ids = new Set();
182
309
  for (const subtask of draft.subtasks) {
183
- if (!subtask?.key || !subtask.title?.trim() || !subtask.content?.trim()) {
184
- throw new Error('子任務 draft 缺少 key、title 或 content');
310
+ if (!subtask?.id || !subtask.title?.trim() || !subtask.content?.trim()) {
311
+ throw new Error('Frontend 子任務 draft 缺少 id、title 或 content');
185
312
  }
313
+ if (ids.has(subtask.id)) throw new Error(`Frontend 子任務 draft ID 重複:${subtask.id}`);
314
+ ids.add(subtask.id);
186
315
  }
316
+ const normalizedAnalysis = normalizeFrontendAnalysis({
317
+ schemaVersion: draft.schemaVersion,
318
+ issueId: draft.parent.id,
319
+ analysisScope: draft.analysisScope,
320
+ isIndivisible: Boolean(draft.isIndivisible),
321
+ indivisibleReason: draft.indivisibleReason || '',
322
+ scopeDecision: draft.scopeDecision,
323
+ evidence: Array.isArray(draft.evidence) ? draft.evidence : [],
324
+ unresolvedItems: Array.isArray(draft.unresolvedItems) ? draft.unresolvedItems : [],
325
+ subtasks: draft.subtasks,
326
+ });
327
+ validateFrontendAnalysis(normalizedAnalysis, draft.parent.id, maxSubtasks);
187
328
  }
188
329
 
189
330
  /**