ai-git-tools 2.1.13 → 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.
@@ -0,0 +1,349 @@
1
+ import {
2
+ analyzeSubtasks as defaultAnalyzeSubtasks,
3
+ MIN_MAX_SUBTASKS,
4
+ MAX_MAX_SUBTASKS,
5
+ normalizeFrontendAnalysis,
6
+ validateFrontendAnalysis,
7
+ } from './subtask-analyzer.js';
8
+ import {
9
+ formatSubtaskContent,
10
+ getManagedSubtaskKey,
11
+ buildManagedSubtaskKey,
12
+ } from './subtask-formatters.js';
13
+
14
+ function getProjectId(issue = {}) {
15
+ return issue.projectId ?? issue.project?.id ?? null;
16
+ }
17
+
18
+ function getTrackerId(issue = {}) {
19
+ return issue.trackerId ?? issue.tracker?.id ?? null;
20
+ }
21
+
22
+ function sameValue(left, right) {
23
+ return (left ?? null) === (right ?? null);
24
+ }
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
+
37
+ function getParentSnapshot(parent) {
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
+ };
53
+ }
54
+
55
+ function assertParentMetadata(parent) {
56
+ if (getProjectId(parent) === null || getTrackerId(parent) === null) {
57
+ throw new Error(`Issue #${parent.id} 缺少建立子任務需要的 project 或 tracker 資訊`);
58
+ }
59
+ }
60
+
61
+ function sanitizeError(error, client) {
62
+ const secret = client?.apiKey;
63
+ return secret ? String(error?.message || error).replaceAll(secret, '[REDACTED]') : String(error?.message || error);
64
+ }
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
+
80
+ function hasParentConflict(expected, current) {
81
+ return expected.subject !== current.subject
82
+ || expected.description !== current.description
83
+ || !sameValue(getProjectId(expected), getProjectId(current))
84
+ || !sameValue(getTrackerId(expected), getTrackerId(current))
85
+ || JSON.stringify(normalizeRelations(expected.relations)) !== JSON.stringify(normalizeRelations(current.relations));
86
+ }
87
+
88
+ function getExistingChildTitles(children = []) {
89
+ return new Set(children.map(child => String(child.subject || '').trim().toLocaleLowerCase()).filter(Boolean));
90
+ }
91
+
92
+ function getExistingManagedKeys(children = []) {
93
+ return new Set(children.map(child => getManagedSubtaskKey(child.description)).filter(Boolean));
94
+ }
95
+
96
+ async function loadChildDetails(client, children = []) {
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
+ }
112
+ try {
113
+ const detail = await client.getIssue(child.id);
114
+ return { ...child, description: detail.description || '' };
115
+ } catch {
116
+ unavailableChildIds.push(child.id);
117
+ return child;
118
+ }
119
+ }));
120
+ return { children: detailedChildren, unavailableChildIds };
121
+ }
122
+
123
+ /**
124
+ * 產生主 Issue 子任務 draft
125
+ * @param {{parentIssueId: number|string, client: object, analyzeSubtasksFn?: Function, maxSubtasks?: number, model?: string, maxRetries?: number, onProgress?: Function}} options
126
+ * @returns {Promise<object>}
127
+ */
128
+ export async function generateSubtaskDraft({
129
+ parentIssueId,
130
+ client,
131
+ analyzeSubtasksFn = defaultAnalyzeSubtasks,
132
+ maxSubtasks = 8,
133
+ repositoryContext = {},
134
+ model,
135
+ maxRetries,
136
+ onProgress = () => {},
137
+ }) {
138
+ onProgress({ phase: 'read-parent', parentIssueId });
139
+ const parent = await client.getIssue(parentIssueId, { include: 'children,relations' });
140
+ assertParentMetadata(parent);
141
+ onProgress({ phase: 'parent-read', parentIssueId, subject: parent.subject });
142
+ onProgress({ phase: 'scope-analysis', parentIssueId });
143
+ onProgress({ phase: 'analyze-subtasks', parentIssueId });
144
+ const analysis = await analyzeSubtasksFn({
145
+ issue: parent,
146
+ maxSubtasks,
147
+ repositoryContext,
148
+ model,
149
+ maxRetries,
150
+ });
151
+ if (analysis.schemaVersion !== 2) {
152
+ throw new Error('Frontend 子任務分析必須回傳 schemaVersion 2,請重新產生 preview');
153
+ }
154
+ validateFrontendAnalysis(analysis, parentIssueId, maxSubtasks);
155
+ onProgress({ phase: 'subtasks-analyzed', parentIssueId, count: analysis.subtasks.length });
156
+
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
+ });
186
+
187
+ return {
188
+ version: 2,
189
+ schemaVersion: 2,
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 || [],
198
+ parent: getParentSnapshot(parent),
199
+ subtasks,
200
+ };
201
+
202
+ }
203
+
204
+ /**
205
+ * 套用已審核的子任務 draft
206
+ * @param {{draft: object, client: object, force?: boolean, onProgress?: Function}} options
207
+ * @returns {Promise<Array<object>>}
208
+ */
209
+ export async function applySubtaskDraft({ draft, client, force = false, onProgress = () => {} }) {
210
+ validateSubtaskDraft(draft);
211
+ const parentIssueId = draft.parent.id;
212
+ const current = await client.getIssue(parentIssueId, { include: 'children,relations' });
213
+ if (!force && hasParentConflict(draft.parent, current)) {
214
+ return [{ parentIssueId, blocked: true, reason: 'conflict' }];
215
+ }
216
+ assertParentMetadata(draft.parent);
217
+
218
+ const childDetails = await loadChildDetails(
219
+ client,
220
+ Array.isArray(current.children) ? current.children : []
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;
231
+ const managedKeys = getExistingManagedKeys(children);
232
+ const titles = getExistingChildTitles(children);
233
+ const results = [];
234
+ for (const [index, subtask] of draft.subtasks.entries()) {
235
+ const subtaskId = subtask.id || subtask.key;
236
+ onProgress({
237
+ phase: 'create-child',
238
+ index: index + 1,
239
+ total: draft.subtasks.length,
240
+ parentIssueId,
241
+ key: subtaskId,
242
+ title: subtask.title,
243
+ });
244
+ const managedKey = buildManagedSubtaskKey(parentIssueId, subtaskId);
245
+ if (managedKeys.has(managedKey) || titles.has(String(subtask.title).trim().toLocaleLowerCase())) {
246
+ results.push({
247
+ ...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
248
+ title: subtask.title,
249
+ skipped: true,
250
+ reason: 'duplicate',
251
+ });
252
+ continue;
253
+ }
254
+
255
+ try {
256
+ const child = await client.createIssue({
257
+ projectId: getProjectId(draft.parent),
258
+ trackerId: getTrackerId(draft.parent),
259
+ subject: subtask.title,
260
+ description: subtask.content,
261
+ parentIssueId,
262
+ });
263
+ results.push({
264
+ ...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
265
+ title: subtask.title,
266
+ created: true,
267
+ childId: child?.id ?? child?.issue?.id ?? null,
268
+ });
269
+ managedKeys.add(managedKey);
270
+ titles.add(String(subtask.title).trim().toLocaleLowerCase());
271
+ } catch (error) {
272
+ results.push({
273
+ ...(draft.version === 2 ? { id: subtaskId } : { key: subtaskId }),
274
+ title: subtask.title,
275
+ created: false,
276
+ error: sanitizeError(error, client),
277
+ });
278
+ }
279
+ }
280
+ return results;
281
+ }
282
+
283
+ /**
284
+ * 驗證子任務 draft 基本結構
285
+ * @param {object} draft
286
+ */
287
+ export function validateSubtaskDraft(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 格式不受支援');
304
+ }
305
+ if (draft.parent.id === undefined || draft.parent.id === null) {
306
+ throw new Error('子任務 draft 缺少 parent Issue ID');
307
+ }
308
+ const ids = new Set();
309
+ for (const subtask of draft.subtasks) {
310
+ if (!subtask?.id || !subtask.title?.trim() || !subtask.content?.trim()) {
311
+ throw new Error('Frontend 子任務 draft 缺少 id、title 或 content');
312
+ }
313
+ if (ids.has(subtask.id)) throw new Error(`Frontend 子任務 draft ID 重複:${subtask.id}`);
314
+ ids.add(subtask.id);
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);
328
+ }
329
+
330
+ /**
331
+ * 將子任務 draft 序列化為 JSON
332
+ * @param {object} draft
333
+ * @returns {string}
334
+ */
335
+ export function serializeSubtaskDraft(draft) {
336
+ validateSubtaskDraft(draft);
337
+ return `${JSON.stringify(draft, null, 2)}\n`;
338
+ }
339
+
340
+ /**
341
+ * 解析子任務 draft JSON
342
+ * @param {string} content
343
+ * @returns {object}
344
+ */
345
+ export function parseSubtaskDraft(content) {
346
+ const draft = JSON.parse(content);
347
+ validateSubtaskDraft(draft);
348
+ return draft;
349
+ }